From ae6f1b1ed105b87c13c230621ab0a99127c7b519 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 16:56:00 +0100 Subject: [PATCH 01/30] fix(sync): reuse semaphore and mutex storage only through destroy emb_sem_init() and emb_mutex_init() reset whatever storage they were given, so initializing storage that still held an active object lost its waiters, owner and bindings without a diagnostic (the KRN-OBJ-007 gap recorded by the M2-02 contract review). CONFIG_EMB_OBJ_LIST (KRN-OBJ-011, default y except on the tiny profile) keeps every active semaphore and mutex on a per-type list from init to destroy. Init looks the storage up in that list under the scheduler lock and refuses a live object: EMB_FAULT_API_LIFECYCLE in checked builds, EMB_EBUSY in release, as thread init already does. The check compares addresses only, so uninitialized storage cannot be mistaken for a live object. The lists are statically initialized and usable before emb_kernel_init(). Without the list the outcome stays undefined. embk_list_contains() replaces the open-coded thread-list walk. Three thread tests and the mutex deadlock test now destroy the objects they create instead of leaving them live for the next test to re-init. New cases sem_storage_reuse_requires_destroy and mutex_storage_reuse_requires_destroy check refusal, the untouched live object (waiter, owner) and reuse after destroy. Signed-off-by: teo1747 --- kernel/Kconfig | 9 +++++++++ kernel/include/embk/list.h | 16 ++++++++++++++++ kernel/include/embk/sync.h | 14 ++++++++++++++ kernel/sync/mutex.c | 22 ++++++++++++++++++++++ kernel/sync/sem.c | 22 ++++++++++++++++++++++ kernel/thread/thread.c | 10 +--------- tests/conformance/test_mutex.c | 23 +++++++++++++++++++++++ tests/conformance/test_sem.c | 19 +++++++++++++++++++ tests/conformance/test_thread.c | 3 +++ 9 files changed, 129 insertions(+), 9 deletions(-) diff --git a/kernel/Kconfig b/kernel/Kconfig index 7d98daa..940bfbd 100644 --- a/kernel/Kconfig +++ b/kernel/Kconfig @@ -215,6 +215,15 @@ config EMB_HANDLE_MODEL string default "POINTER" if EMB_HANDLE_MODEL_POINTER +config EMB_OBJ_LIST + bool "Keep a per-type list of active objects (KRN-OBJ-011)" + default n if EMB_PROFILE_TINY + default y + help + SPEC-009 §6, §7. Init refuses storage that still holds an active + object of its type, exactly, by looking it up in the list. Without + the list, init on such storage is undefined. + endmenu menu "Interrupts" diff --git a/kernel/include/embk/list.h b/kernel/include/embk/list.h index 5fbbcc8..07f47ec 100644 --- a/kernel/include/embk/list.h +++ b/kernel/include/embk/list.h @@ -110,4 +110,20 @@ static EMB_INLINE embk_list_node_t *embk_list_pop_first(embk_list_t *l) return n; } +/* Is @n on @l? Compares addresses only, so @n may be storage that holds no object. */ +static EMB_INLINE bool embk_list_contains(const embk_list_t *l, const embk_list_node_t *n) +{ + const embk_list_node_t *p; + /* bounded by the length of @l */ + for (p = embk_list_first(l); p != NULL; p = embk_list_next(l, p)) { + if (p == n) { + return true; + } + } + return false; +} + +/* A static initializer for an empty list, for lists used before any init code runs. */ +#define EMBK_LIST_INITIALIZER(l) {{&(l).head, &(l).head}} + #endif /* EMBK_LIST_H */ diff --git a/kernel/include/embk/sync.h b/kernel/include/embk/sync.h index 669e86a..235e38f 100644 --- a/kernel/include/embk/sync.h +++ b/kernel/include/embk/sync.h @@ -18,7 +18,14 @@ typedef struct embk_sem { embk_thread_t *bind_thread; /* ADR-027: NULL when unbound */ emb_notify_bits_t bind_bit; #endif +#if CONFIG_EMB_OBJ_LIST + embk_list_node_t all_node; /* in embk_sem_all from init to destroy */ +#endif } embk_sem_t; + +#if CONFIG_EMB_OBJ_LIST +extern embk_list_t embk_sem_all; /* every active semaphore (KRN-OBJ-011); lock: scheduler domain */ +#endif #endif #if CONFIG_EMB_MUTEX @@ -35,8 +42,15 @@ typedef struct embk_mutex { uint8_t ceiling; uint8_t count; /* recursion count, 0 when free */ uint8_t reserved_; +#if CONFIG_EMB_OBJ_LIST + embk_list_node_t all_node; /* in embk_mutex_all from init to destroy */ +#endif } embk_mutex_t; +#if CONFIG_EMB_OBJ_LIST +extern embk_list_t embk_mutex_all; /* every active mutex (KRN-OBJ-011); lock: scheduler domain */ +#endif + /* Recompute and apply @t's effective priority (SPEC-005 §2.1); returns whether it changed. */ bool embk_prio_recompute(embk_thread_t *t); diff --git a/kernel/sync/mutex.c b/kernel/sync/mutex.c index e1a32da..345f671 100644 --- a/kernel/sync/mutex.c +++ b/kernel/sync/mutex.c @@ -20,6 +20,10 @@ EMB_STATIC_ASSERT(sizeof(embk_mutex_t) <= EMB_MUTEX_STORAGE_SIZE, "generated storage too small"); +#if CONFIG_EMB_OBJ_LIST +embk_list_t embk_mutex_all = EMBK_LIST_INITIALIZER(embk_mutex_all); /* usable before kernel init */ +#endif + static EMB_INLINE embk_mutex_t *mutex_from_handle(emb_mutex_t h) { return (embk_mutex_t *)embk_obj_from_raw(h.raw, EMBK_OBJ_MUTEX, offsetof(embk_mutex_t, obj)); @@ -134,6 +138,9 @@ emb_status_t emb_mutex_init(emb_mutex_storage_t *storage, const emb_mutex_attr_t { embk_mutex_t *m; emb_mutex_attr_t def; +#if CONFIG_EMB_OBJ_LIST + emb_irq_key_t key; +#endif if (out_mutex != NULL) { out_mutex->raw = 0u; } @@ -161,6 +168,14 @@ emb_status_t emb_mutex_init(emb_mutex_storage_t *storage, const emb_mutex_attr_t } #endif m = (embk_mutex_t *)(void *)storage; +#if CONFIG_EMB_OBJ_LIST + /* storage is reused only through destroy (SPEC-009 §6, KRN-OBJ-007) */ + key = embk_lock_sched(); + if (embk_list_contains(&embk_mutex_all, &m->all_node)) { + embk_unlock_sched(key); + EMBK_API_MISUSE(EMB_API_ID_MUTEX_INIT, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); + } +#endif embk_obj_init(&m->obj, EMBK_OBJ_MUTEX, attr->flags & (EMB_MUTEX_RECURSIVE | EMB_OBJ_ABORT_WAITERS), attr->name); embk_wait_queue_init(&m->waiters, @@ -171,6 +186,10 @@ emb_status_t emb_mutex_init(emb_mutex_storage_t *storage, const emb_mutex_attr_t m->ceiling = attr->ceiling; m->count = 0u; m->reserved_ = 0u; +#if CONFIG_EMB_OBJ_LIST + embk_list_append(&embk_mutex_all, &m->all_node); + embk_unlock_sched(key); +#endif out_mutex->raw = embk_obj_to_raw(m); return EMB_OK; } @@ -199,6 +218,9 @@ emb_status_t emb_mutex_destroy(emb_mutex_t mutex) m->owner = NULL; embk_prio_propagate(embk_cpu.current); } +#if CONFIG_EMB_OBJ_LIST + embk_list_remove(&m->all_node); +#endif m->obj.state = EMBK_OBJ_UNINIT; embk_sched_reschedule_if_needed(); embk_unlock_object(m, key); diff --git a/kernel/sync/sem.c b/kernel/sync/sem.c index c5e82c1..abe8dd9 100644 --- a/kernel/sync/sem.c +++ b/kernel/sync/sem.c @@ -14,6 +14,10 @@ EMB_STATIC_ASSERT(sizeof(embk_sem_t) <= EMB_SEM_STORAGE_SIZE, "generated storage too small"); +#if CONFIG_EMB_OBJ_LIST +embk_list_t embk_sem_all = EMBK_LIST_INITIALIZER(embk_sem_all); /* usable before kernel init */ +#endif + static EMB_INLINE embk_sem_t *sem_from_handle(emb_sem_t h) { return (embk_sem_t *)embk_obj_from_raw(h.raw, EMBK_OBJ_SEM, offsetof(embk_sem_t, obj)); @@ -49,6 +53,9 @@ emb_status_t emb_sem_init(emb_sem_storage_t *storage, const emb_sem_attr_t *attr { embk_sem_t *s; emb_sem_attr_t def; +#if CONFIG_EMB_OBJ_LIST + emb_irq_key_t key; +#endif if (out_sem != NULL) { out_sem->raw = 0u; } @@ -65,6 +72,14 @@ emb_status_t emb_sem_init(emb_sem_storage_t *storage, const emb_sem_attr_t *attr EMBK_API_REQUIRE(EMB_API_ID_SEM_INIT, attr->max >= 1u && attr->initial <= attr->max, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); s = (embk_sem_t *)(void *)storage; +#if CONFIG_EMB_OBJ_LIST + /* storage is reused only through destroy (SPEC-009 §6, KRN-OBJ-007) */ + key = embk_lock_sched(); + if (embk_list_contains(&embk_sem_all, &s->all_node)) { + embk_unlock_sched(key); + EMBK_API_MISUSE(EMB_API_ID_SEM_INIT, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); + } +#endif embk_obj_init(&s->obj, EMBK_OBJ_SEM, attr->flags & (EMB_SEM_SATURATE | EMB_OBJ_ABORT_WAITERS | EMB_OBJ_FIFO), attr->name); @@ -74,6 +89,10 @@ emb_status_t emb_sem_init(emb_sem_storage_t *storage, const emb_sem_attr_t *attr #if CONFIG_EMB_NOTIFY s->bind_thread = NULL; s->bind_bit = 0u; +#endif +#if CONFIG_EMB_OBJ_LIST + embk_list_append(&embk_sem_all, &s->all_node); + embk_unlock_sched(key); #endif out_sem->raw = embk_obj_to_raw(s); return EMB_OK; @@ -93,6 +112,9 @@ emb_status_t emb_sem_destroy(emb_sem_t sem) } (void)embk_wait_flush(&s->waiters, EMBK_WAKE_DESTROYED); } +#if CONFIG_EMB_OBJ_LIST + embk_list_remove(&s->all_node); +#endif s->obj.state = EMBK_OBJ_UNINIT; embk_sched_reschedule_if_needed(); embk_unlock_object(s, key); diff --git a/kernel/thread/thread.c b/kernel/thread/thread.c index f4b5530..f253c1b 100644 --- a/kernel/thread/thread.c +++ b/kernel/thread/thread.c @@ -56,15 +56,7 @@ static void index_release(uint8_t index) static bool storage_is_live(const embk_thread_t *t) { #if CONFIG_EMB_THREAD_LIST - const embk_list_node_t *n; - /* bounded by the number of live threads */ - for (n = embk_list_first(&embk_thread_all); n != NULL; - n = embk_list_next(&embk_thread_all, n)) { - if (n == &t->all_node) { - return true; - } - } - return false; + return embk_list_contains(&embk_thread_all, &t->all_node); /* O(live threads) */ #else uint8_t index; if (t->obj.type != EMBK_OBJ_THREAD || t->obj.state != EMBK_OBJ_ACTIVE) { diff --git a/tests/conformance/test_mutex.c b/tests/conformance/test_mutex.c index d7ac1d0..ff29312 100644 --- a/tests/conformance/test_mutex.c +++ b/tests/conformance/test_mutex.c @@ -194,6 +194,8 @@ EMB_TEST(mutex_deadlock_is_detected_on_the_chain) deadlock_scenario(); EMB_ASSERT_EQ(deadlock_status, EMB_EDEADLK); #endif + EMB_ASSERT_OK(emb_mutex_destroy(m1)); + EMB_ASSERT_OK(emb_mutex_destroy(m2)); } static void ceiling_locker(void *arg) @@ -273,6 +275,27 @@ EMB_TEST(mutex_destroy_rules) EMB_ASSERT_MARKS(1, 2, 3, 12); } +#if CONFIG_EMB_OBJ_LIST +EMB_TEST(mutex_storage_reuse_requires_destroy) +{ + emb_mutex_t other; + EMB_TEST_REQ("KRN-OBJ-007", "KRN-OBJ-011"); + make(&st1, &m1, EMB_MUTEX_INHERIT, 0u, 0u); + EMB_ASSERT_OK(emb_mutex_lock(m1, EMB_NO_WAIT)); + (void)emb_test_thread(EMB_TEST_NAME("w"), 3u, 0u, lock_m1_report, (void *)100); + EMB_ASSERT_MISUSE(emb_mutex_init(&st1, NULL, &other), EMB_EBUSY); +#if !CONFIG_EMB_CHECKED + EMB_ASSERT_EQ(other.raw, 0u); +#endif + EMB_ASSERT_TRUE(emb_mutex_is_owner(m1)); /* ownership and the waiter are untouched */ + EMB_ASSERT_OK(emb_mutex_unlock(m1)); + EMB_ASSERT_MARKS(10); + EMB_ASSERT_OK(emb_mutex_destroy(m1)); + make(&st1, &m1, EMB_MUTEX_INHERIT, 0u, 0u); /* destroyed storage is reusable */ + EMB_ASSERT_OK(emb_mutex_destroy(m1)); +} +#endif + static void lock_mark_unlock(void *arg) { int id = (int)(intptr_t)arg; diff --git a/tests/conformance/test_sem.c b/tests/conformance/test_sem.c index a293752..50ccd8e 100644 --- a/tests/conformance/test_sem.c +++ b/tests/conformance/test_sem.c @@ -157,6 +157,25 @@ EMB_TEST(sem_destroy_with_waiters_is_misuse) EMB_ASSERT_OK(emb_sem_destroy(sem)); } +#if CONFIG_EMB_OBJ_LIST +EMB_TEST(sem_storage_reuse_requires_destroy) +{ + emb_sem_t other; + EMB_TEST_REQ("KRN-OBJ-007", "KRN-OBJ-011"); + make(NULL); + (void)emb_test_thread(EMB_TEST_NAME("t3"), 3u, 0u, taker, (void *)3); + EMB_ASSERT_MISUSE(emb_sem_init(&storage, NULL, &other), EMB_EBUSY); +#if !CONFIG_EMB_CHECKED + EMB_ASSERT_EQ(other.raw, 0u); +#endif + EMB_ASSERT_OK(emb_sem_give(sem)); /* the live semaphore and its waiter are untouched */ + EMB_ASSERT_MARKS(3); + EMB_ASSERT_OK(emb_sem_destroy(sem)); + make(NULL); /* destroyed storage is reusable */ + EMB_ASSERT_OK(emb_sem_destroy(sem)); +} +#endif + EMB_TEST(sem_blocking_take_from_prekernel_or_isr_rejected) { EMB_TEST_REQ("API-012", "API-013"); diff --git a/tests/conformance/test_thread.c b/tests/conformance/test_thread.c index ea63bae..17b1204 100644 --- a/tests/conformance/test_thread.c +++ b/tests/conformance/test_thread.c @@ -212,6 +212,7 @@ EMB_TEST(thread_wake_while_suspended_is_applied_at_resume) EMB_ASSERT_OK(emb_thread_resume(h)); EMB_ASSERT_MARKS(1); EMB_ASSERT_OK(emb_thread_join(h, EMB_WAIT_FOREVER, NULL)); + EMB_ASSERT_OK(emb_sem_destroy(sem)); } static void sleeper_reports(void *arg) @@ -252,6 +253,7 @@ EMB_TEST(thread_cancel_pending_delivered_at_next_point) EMB_ASSERT_OK(emb_sem_give(sem)); EMB_ASSERT_OK(emb_thread_join(h, EMB_WAIT_FOREVER, NULL)); EMB_ASSERT_MARKS(1, 3, 5); + EMB_ASSERT_OK(emb_sem_destroy(sem)); } EMB_TEST(thread_cancel_point_and_counts) @@ -283,6 +285,7 @@ EMB_TEST(thread_priority_change_requeues_waiter) EMB_ASSERT_OK(emb_sem_give(sem)); /* a is now first */ EMB_ASSERT_OK(emb_sem_give(sem)); EMB_ASSERT_MARKS(2, 3); + EMB_ASSERT_OK(emb_sem_destroy(sem)); } EMB_TEST(thread_state_reports_lifecycle) From 240fbdaeb7d6e0272306f7dfc84cacacbf395d67 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 17:47:24 +0100 Subject: [PATCH 02/30] docs(obj): record ADR-045 and the object lifecycle review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-045 records why semaphore and mutex init detect live storage through per-type lists rather than header reads, and why every type refuses with EMB_EBUSY. KRN-OBJ-007 is amended accordingly (EMB_EBUSY, detection scope per configuration) and KRN-OBJ-011 is marked partially implemented. SPEC-009 §6, §7 and the §8 misuse table follow. The init annotations of emb_sem_init(), emb_mutex_init() and emb_thread_init() now state the refusal and the list-walk costs; thread init's walk of the all-threads list had not been mentioned. docs/engineering/m2-02-object-lifecycle.md is the review the last M2-02 checklist items asked for: the defect, the decision, the per-object init/destroy/reuse table, the cost review, storage and code size deltas, and the executed evidence. STATUS, the changelog and the checklist are updated. Signed-off-by: teo1747 --- CHANGELOG.md | 7 ++ docs/STATUS.md | 4 +- docs/architecture/06-decision-records.md | 12 ++++ docs/engineering/index.md | 1 + docs/engineering/m2-02-object-lifecycle.md | 66 +++++++++++++++++++ docs/engineering/m2-checklist.md | 4 +- docs/requirements/KRN-OBJ.md | 10 +-- ...EC-009-objects-capabilities-and-storage.md | 6 +- include/emb/mutex.h | 12 ++-- include/emb/sem.h | 12 ++-- include/emb/thread.h | 3 +- 11 files changed, 116 insertions(+), 21 deletions(-) create mode 100644 docs/engineering/m2-02-object-lifecycle.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c6279dd..936911a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,15 @@ User-visible changes are recorded here. There are no released versions or releas ## Unreleased +### Changed + +- `emb_sem_init()` and `emb_mutex_init()` refuse storage that still holds an active object of their type (fault in checked builds, `EMB_EBUSY` in release) instead of overwriting it, when `CONFIG_EMB_OBJ_LIST` is on (default except on the tiny profile). Storage is reused only through destroy. Semaphore and mutex storage grows by one list node where the option is on. KRN-OBJ-007 now specifies `EMB_EBUSY` instead of `EMB_EEXIST` (ADR-045). + ### Added +- `CONFIG_EMB_OBJ_LIST`: per-type lists of active semaphores and mutexes (KRN-OBJ-011). +- A `.clangd` and exported `compile_commands.json` for editor support; `.clang-tidy` disables, with reasons, the checks that contradict the coding standard. + - Stable numeric IDs in public API misuse fault records, a generated header and downloadable decoder map, with checked-in assignments and compatibility checks. Shared helpers retain the public caller ID and location strings remain optional. - The Arduino Uno conformance suite on EmbSIM's simulated Uno (`avr-uno-embsim` preset, diff --git a/docs/STATUS.md b/docs/STATUS.md index abf7e35..8ac9495 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,7 +12,7 @@ This page is the authoritative inventory of implementation and available evidenc | Waiting and time | Shared wait protocol, sleep, deadlines, cancellation, suspend overlay, ordered timeouts | Native virtual time exercises periodic and tickless modes; embedded ports currently reject tickless | | Synchronization | Semaphores, mutexes with inheritance, ceiling, recursion and owner-death policy | Conformance and native differential bridge; inheritance and deadlock detection have a configured depth limit | | Notifications | Per-thread bits and one semaphore-to-thread binding | Binding takes a one-bit mask; no unbind implementation; pointer lifetime remains caller responsibility | -| Objects | Caller-owned generated storage and POINTER handles | No INDEXED or TABLE handles, capability rights, partition restart or system freeze implementation | +| Objects | Caller-owned generated storage and POINTER handles; per-type active-object lists for semaphores and mutexes (`CONFIG_EMB_OBJ_LIST`) used by init | No INDEXED or TABLE handles, capability rights, partition restart, system freeze, `emb_obj_foreach()` or debug-descriptor export | | Native port | POSIX host-thread gate, deterministic IRQ injection, virtual time | Base/tiny development workflow; no current wall-clock, asynchronous signal stress, simulated isolated profile or Win32 backend | | Embedded ports | ATmega328P; Cortex-M3/M4F; RV32IMAC and RV32IMC machine mode; boards Arduino Uno, NUCLEO-F446RE, ESP32-C3-DevKitM-1, QEMU's MPS2 and RV32 virt | QEMU jobs and EmbSIM jobs (Uno, NUCLEO-F446RE, ESP32-C3-DevKitM-1) defined in CI; no real-board timing or peripheral/HIL validation recorded here | | Configuration/build | Kconfig subset, port manifests, CMake/Ninja, layout probe, footprint tool | GCC/Clang native CI and cross-GCC QEMU jobs; ten legacy EmbCC capabilities reviewed with scoped local checks, but current RTOS compiler CI/qualification remains incomplete | @@ -70,7 +70,7 @@ The [EmbCC layout-marker correction](engineering/embcc-layout-marker.md), source - Embedded fault-expectation cases that require host `fork` are skipped. A passing emulator job does not establish those misuse paths on hardware. - The [M2-02 IRQ correction](engineering/m2-02-irq-priority.md) implements larger-is-more-urgent in the advertised kernel-aware domain: Cortex-M derives the range/encoding from board priority width and BASEPRI; native, AVR and current RV32 expose level 0 only. Invalid requests fault when checked or return an error in release before controller writes. SysTick defaults to highest kernel-aware urgency. Initial QEMU M3/M4 and F446 EmbSIM runs passed the new register/order/preemption cases; local checked/release/tiny/periodic native and experimental EmbCC-kernel/GCC-host checked builds each passed 13 CTest targets. Physical latency/jitter, raw kernel-independent vectors and generic hardware-description generation remain unqualified/unimplemented; the raw class is refused at configuration. - Thread diagnostic indexes are now independent of priority in every scheduler profile, unique among live objects and reserved through termination until successful destroy. A bounded bitmap replaces base's saturating counter. See the [identity report](engineering/m2-02-thread-identity.md) for admission limits, costs and verification. Public API misuse faults now carry stable numeric entry IDs, including shared-helper paths; generic/manual faults keep ID zero. See the [fault attribution report](engineering/m2-02-fault-api-ids.md). Retained crash records and full observability remain incomplete. -- Thread init refuses storage that still holds an undestroyed thread (fault when checked, `EMB_EBUSY` in release): exact with the all-threads list, otherwise from the type tag, active state and reserved identity. Semaphore and mutex init paths still lack active-storage double-init detection. Diagnostic ID reuse after destroy does not add generation checks to POINTER handles; storage lifetime and external synchronization remain caller obligations. The [M2-02 review](engineering/m2-02-contract-review.md) records the remaining lifecycle proposals. +- Thread init refuses storage that still holds an undestroyed thread (fault when checked, `EMB_EBUSY` in release): exact with the all-threads list, otherwise from the type tag, active state and reserved identity. Semaphore and mutex init refuse storage holding an active object the same way, exactly, with `CONFIG_EMB_OBJ_LIST` (default except tiny); on tiny their double init remains undefined. See the [object lifecycle report](engineering/m2-02-object-lifecycle.md). Diagnostic ID reuse after destroy does not add generation checks to POINTER handles; storage lifetime and external synchronization remain caller obligations. The [M2-02 review](engineering/m2-02-contract-review.md) records the remaining lifecycle proposals. - The former ten-item EmbCC compiler backlog is implemented upstream and the RTOS layout marker is supplied. An explicit mixed Linux native strategy now executes the kernel; all-EmbCC POSIX host compatibility, AVR flash/registration macros and compiler-matrix CI/qualification remain open. Available CMSE, atomics, DWARF and embedded C++ retain their documented scope and restrictions; see the [capability review](engineering/embcc-capability-review.md) and [marker correction](engineering/embcc-layout-marker.md). - Planned isolation must meet SPEC-010's DMA, pointer lifetime, generation and restart obligations before fault containment is claimed. CPU protection alone is insufficient. diff --git a/docs/architecture/06-decision-records.md b/docs/architecture/06-decision-records.md index 951e5cd..1779b98 100644 --- a/docs/architecture/06-decision-records.md +++ b/docs/architecture/06-decision-records.md @@ -527,3 +527,15 @@ Each record: context, decision, alternatives, consequences, status. **Proposed** **Consequences.** Firmware requires no API-name lookup table or new mutable state. Export the decoder map with firmware; it can be consumed by future EmbDebug integration without claiming that integration exists. Checked/release policies, argument numbering and error precedence are preserved. CI checks generated output, declaration coverage and compatibility with the PR/push baseline. Retained crash serialization remains separate. **Status.** Implementation for review, 2026-10-21, authorized by the project owner. See the fault attribution report for executed validation and exclusions. + +## ADR-045 Exact live-storage detection through per-type object lists + +**Context.** KRN-OBJ-007 required `init` on active storage to fail with `EMB_EEXIST`, and SPEC-009 §6 detected it by reading the header. Semaphore and mutex init instead reset any storage, silently discarding waiters, an owner or a binding. The M2-02 review rejected header reads: uninitialized automatic storage can hold an `ACTIVE` header by accident, and refusing it would fail a correct program. Thread init already refuses undestroyed storage with `EMB_EBUSY` (SPEC-008 §12). + +**Decision.** Implement the per-type lists of KRN-OBJ-011 as `CONFIG_EMB_OBJ_LIST`, default on except on the tiny profile. An active semaphore or mutex is on its type's list from `init` to `destroy`; `init` compares the storage's list node with the list's members under the scheduler lock and refuses a match, faulting in checked builds and returning `EMB_EBUSY` in release builds, before touching the storage. The lists are statically initialized, so prekernel construction works before `emb_kernel_init()`. Use `EMB_EBUSY` for every type, matching thread init and the in-use destroy refusals of KRN-OBJ-010; `EMB_EEXIST` stays the status of a duplicate binding. Without a list, `init` on live storage is undefined and documented as such. + +**Alternatives.** Header plausibility checks (type, state, field ranges or self-pointers) reduce but cannot remove false refusals of uninitialized storage, and dereferencing a garbage queue pointer could crash. Requiring zeroed storage would add an obligation to every automatic or heap object. A generation word per object belongs to the INDEXED/TABLE models (SPEC-009 §3) and does not by itself identify live storage. + +**Consequences.** Each semaphore and mutex grows by one list node (two pointers) where the list is configured; the generated storage types follow automatically. Init costs a walk of the active objects of its type with interrupts masked. Tiny keeps its RAM, flash and init cost, and its semaphore/mutex double init stays undefined. New object types (event flags, condition variables, queues, work queues, timers) join the same scheme. POINTER handles still carry no generation: a stale copy of a handle to destroyed and re-initialized storage refers to the new object (SPEC-009 §3.1). + +**Status.** Implemented for review, 2026-10-27, as part of completing M2-02; awaiting project-owner review. diff --git a/docs/engineering/index.md b/docs/engineering/index.md index 84fbc3b..e9f5349 100644 --- a/docs/engineering/index.md +++ b/docs/engineering/index.md @@ -17,6 +17,7 @@ m2-02-contract-review m2-02-irq-priority m2-02-thread-identity m2-02-fault-api-ids +m2-02-object-lifecycle embcc-capability-review embcc-layout-marker embcc-native-linux diff --git a/docs/engineering/m2-02-object-lifecycle.md b/docs/engineering/m2-02-object-lifecycle.md new file mode 100644 index 0000000..124576f --- /dev/null +++ b/docs/engineering/m2-02-object-lifecycle.md @@ -0,0 +1,66 @@ +# M2-02: Object lifecycle and storage reuse + +This closes the last open M2-02 item of the [checklist](m2-checklist.md): review the current POINTER-handle and lifecycle error behavior and the stated costs against the code, and correct what disagrees. The [contract review](m2-02-contract-review.md) recorded the gap: thread, semaphore and mutex init reset whatever storage they were given. Thread init was corrected separately (SPEC-008 §12). This change corrects semaphores and mutexes under ADR-045 and amends KRN-OBJ-007. + +## Defect + +`emb_sem_init()` and `emb_mutex_init()` on storage that still held an active object succeeded and overwrote it. A blocked waiter stayed enqueued in a queue that no longer listed it. A mutex owner lost ownership while its owned list still linked the mutex. A semaphore binding vanished. Nothing was reported in either build. The new conformance cases demonstrate it on the previous kernel (source `3bd0cf7`, cases forced on): checked builds report no fault, release builds return `EMB_OK` with a new handle, and the mutex case observes that the original owner no longer owns its mutex. + +## Decision + +`CONFIG_EMB_OBJ_LIST` (KRN-OBJ-011) keeps every active semaphore and mutex on a per-type list (`embk_sem_all`, `embk_mutex_all`) from init to destroy, under the scheduler lock. Init compares the storage's list node with the members before touching anything, then refuses a match with `EMB_FAULT_API_LIFECYCLE` (checked) or `EMB_EBUSY` (release), argument 1. The comparison is by address, so uninitialized storage is never mistaken for a live object, whatever bytes it holds. The lists are statically initialized, so construction before `emb_kernel_init()` keeps working. The option defaults on, except for the tiny profile, whose flash budget has no room for it; there, double init of a semaphore or mutex stays undefined and is documented as such. + +`EMB_EBUSY` replaces KRN-OBJ-007's `EMB_EEXIST` for every type, matching the thread refusal already shipped and the in-use destroy refusals of KRN-OBJ-010. ADR-045 records the alternatives: header plausibility checks, mandatory zeroed storage, and per-object generations. + +## Lifecycle review + +| Object | Init refuses | Destroy refuses (checked fault / release status) | Storage reusable | +|---|---|---|---| +| Thread | storage holding an undestroyed thread (exact with `CONFIG_EMB_THREAD_LIST`, else tag + state + reserved ID); tiny priority slot taken (`EMB_EBUSY`); IDs exhausted (`EMB_ENOMEM`) | started and not (terminated, joined or detached, switched out): `EMB_EBUSY`; kernel thread: `EMB_EPERM` | after destroy | +| Semaphore | storage holding an active semaphore, with `CONFIG_EMB_OBJ_LIST` | waiters without `EMB_OBJ_ABORT_WAITERS`: `EMB_EBUSY` | after destroy | +| Mutex | storage holding an active mutex, with `CONFIG_EMB_OBJ_LIST` | owned by another thread, or waiters without `EMB_OBJ_ABORT_WAITERS`: `EMB_EBUSY` | after destroy; a mutex the caller owns is released by destroy | + +Argument validation precedes the storage check in all three init paths. The refusal leaves the output handle cleared, as for every init failure. A refused destroy changes nothing. + +POINTER handles carry no generation (SPEC-009 §3.1). After destroy, a retained copy fails the active-state check only while the storage stays destroyed; after re-init, the copy refers to the new object. Handles to storage that is no longer accessible are not detected. Neither case is promised to return `EMB_EINVAL`. Storage lifetime and invalidation of copies remain caller obligations. Semaphore bindings still have no teardown, which is M2-03. + +## Cost review + +| Function | Annotation before | Code | Annotation now | +|---|---|---|---| +| `emb_thread_init` | bounded ID scan + stack fill | also walks the all-threads list | adds O(live threads) with `CONFIG_EMB_THREAD_LIST` | +| `emb_sem_init` | O(1) | O(1); O(active semaphores) with the list | O(1); O(active semaphores) with `CONFIG_EMB_OBJ_LIST` | +| `emb_mutex_init` | O(1) | O(1); O(active mutexes) with the list | O(1); O(active mutexes) with `CONFIG_EMB_OBJ_LIST` | +| `emb_sem_destroy`, `emb_mutex_destroy` | unchanged | list removal is O(1) | unchanged | + +The list walk runs with interrupts masked. Init is not a real-time path: its contexts are prekernel and thread. An application that creates many objects of one type at run time pays the walk once per init. The inheritance depth bound, the deadlock-detection depth bound and the full resource-bound inventory stay with the contract review and M2-04. + +Generated storage, `EMB_*_STORAGE_SIZE` in bytes, before and after: + +| Configuration | Semaphore | Mutex | Thread | +|---|---|---|---| +| native base (64-bit, 16-byte storage alignment) | 64 → 80 | 64 → 80 | 352 (unchanged) | +| native tiny | 48 (unchanged) | 48 (unchanged) | 256 (unchanged) | +| Cortex-M4F base (`cm4-an386`) | 36 → 44 | 32 → 40 | 120 (unchanged) | +| Cortex-M4F tiny | 24 (unchanged) | 24 (unchanged) | 64 (unchanged) | + +On `cm4-an386`, the kernel archive grows from 11,208 to 11,432 bytes of text (semaphore +116, mutex +116, thread −8 from the shared list helper) and gains 16 bytes of data for the two list heads. The tiny archive is byte-for-byte unchanged in text, data and bss, so the AVR tiny footprint gates should be unaffected; they run in CI. + +## Verification + +Source `ae6f1b1` on macOS 27.2 (arm64), CMake 4.4.3, Ninja 1.13.2, QEMU 11.1.1; native with Apple clang 21.0.0 (`gcc`) and Homebrew clang 23.1.0, Cortex-M with Arm GNU Toolchain 14.2.Rel1. + +| Configuration | CTest targets | New cases | +|---|---|---| +| native-gcc, native-clang, native-periodic (checked) | 15/15 | both pass; the refusal is observed as a fault in a forked child | +| native-release | 15/15 | both pass; `EMB_EBUSY`, cleared output, live object intact | +| native-tiny | 15/15 | compiled out (no list) | +| cm3-an385, cm4-an386 (checked, QEMU) | 12/12, 13/13 | pass with the fault assertion skipped (no `fork` on target) | +| cm4-an386-release (QEMU) | 13/13 | both pass; refusal observed | +| cm4-an386-tiny (QEMU) | 13/13 | compiled out | + +Each case blocks a waiter on the live object (and, for the mutex, owns it), attempts re-init, checks the refusal and the cleared output in release, then checks that the waiter is still served and the owner still owns the mutex, and finally reuses the storage after destroy. Four existing tests left objects alive at their end and now destroy them. + +The strict documentation build could only be approximated locally, with Doxygen 1.18.0 standing in for the pinned 1.15.0: it reports the same seven warnings in generated API pages for unchanged headers on `main` (`3bd0cf7`) as on this change, so the documentation job's pinned toolchain is authoritative here. + +Not run locally: AVR, RV32 and the EmbSIM board jobs, whose toolchains or simulator versions are unavailable on this machine; they run in CI. No hardware is involved. The SMP `DESTROYING` state of KRN-OBJ-007 remains unimplemented on uniprocessor builds. diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index 11ea2d5..a813dda 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -43,8 +43,8 @@ The [contract review](m2-02-contract-review.md) records source-grounded proposal - [x] Reconcile Cortex-M `emb_irq_set_level()` in the current kernel-aware domain with larger-is-more-urgent; advertise fixed-port limits and verify encodings/pending order/nested delivery under QEMU and F446 EmbSIM. Raw-class/generator scope and physical measurements remain separate; see the IRQ report. - [x] Give every profile a separate stable diagnostic index and bounded allocation/reclamation; preserve scheduler slots and historical trace replay. See the identity report for tests and resource costs. - [x] Give public misuse faults explicit stable API IDs, preserve wrapper attribution through helpers, export a decoder map, and test class/argument/context/identity including location strings disabled. See the fault attribution report; retained crash format remains separate. -- [ ] Review current POINTER-handle/lifecycle error behavior and resource costs against comments; do not promise generation-based stale rejection or unrestricted cycle detection. -- [ ] Add relevant checked/release and port/model evidence. Hardware priority observation stays unrun until an appropriate emulator/board test is actually executed. +- [x] Review current POINTER-handle/lifecycle error behavior and resource costs against comments; do not promise generation-based stale rejection or unrestricted cycle detection. See the [object lifecycle report](m2-02-object-lifecycle.md): semaphore/mutex init now refuses live storage with `CONFIG_EMB_OBJ_LIST` (ADR-045); init cost annotations corrected. Depth bounds and the full resource inventory move to M2-04. +- [x] Add relevant checked/release and port/model evidence. Native checked/release/tiny/periodic and QEMU Cortex-M checked/release/tiny ran locally for the lifecycle change; embedded checked builds skip the fork-based fault assertion. No model change: storage reuse is not a modeled transition. Hardware priority observation stays unrun until an appropriate emulator/board test is actually executed. The current docs disclose these gaps. Correcting one must update its contract, regression case and implementation together; documentation coverage is not a fix. diff --git a/docs/requirements/KRN-OBJ.md b/docs/requirements/KRN-OBJ.md index 24163bb..ed0eee9 100644 --- a/docs/requirements/KRN-OBJ.md +++ b/docs/requirements/KRN-OBJ.md @@ -53,10 +53,10 @@ KRN-OBJ-001 to 005 originate in `docs/architecture/03-kernel-architecture.md` § **Trace.** SPEC-009 §2 ### KRN-OBJ-007 Lifecycle states -**Statement.** Objects shall move `UNINIT` to `ACTIVE` at init, `ACTIVE` to `DESTROYING` to `UNINIT` at destroy; init on active storage shall be `EMB_EEXIST` (fault in checked builds); operations on a `DESTROYING` object shall return `EMB_ESTATE`. -**Rationale.** Double init and destroy races have defined outcomes. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `misuse/double_init`, SMP test `obj/op_during_destroy_estate`. +**Statement.** Objects shall move `UNINIT` to `ACTIVE` at init, `ACTIVE` to `DESTROYING` to `UNINIT` at destroy; storage shall be reused only through destroy, and init on storage that still holds an active object of its type shall be `EMB_EBUSY` (fault in checked builds) wherever the configuration can tell such storage apart; operations on a `DESTROYING` object shall return `EMB_ESTATE`. +**Rationale.** Double init and destroy races have defined outcomes. The refusal uses the status of the other in-use refusals (KRN-OBJ-010) and must not misread uninitialized storage as live. +**Status.** Accepted 2026-10-07; amended 2026-10-27 (ADR-045): `EMB_EBUSY` instead of `EMB_EEXIST`, detection scope per configuration. Detection is exact with `CONFIG_EMB_THREAD_LIST` (threads) and `CONFIG_EMB_OBJ_LIST` (semaphores, mutexes); tiny threads use the type tag, active state and reserved identity; without a list, init on such semaphore or mutex storage is undefined. `DESTROYING` is not entered on uniprocessor builds. +**Verification.** Test: `thread_storage_reuse_requires_destroy`, `sem_storage_reuse_requires_destroy`, `mutex_storage_reuse_requires_destroy`; SMP test `obj/op_during_destroy_estate`. **Trace.** SPEC-009 §6 ### KRN-OBJ-008 Generated storage types @@ -83,7 +83,7 @@ KRN-OBJ-001 to 005 originate in `docs/architecture/03-kernel-architecture.md` § ### KRN-OBJ-011 Names and registry **Statement.** With `CONFIG_EMB_OBJ_NAMES` objects shall keep their attribute name by pointer; with `CONFIG_EMB_OBJ_LIST` every active object shall be on a per-type list iterable under the scheduler lock; the debug descriptor shall export header offsets, type tags, list heads, and the registry or capability table layout. **Rationale.** Debuggers and statistics enumerate objects without symbols (ADR-011). -**Status.** Accepted 2026-10-07 +**Status.** Accepted 2026-10-07. Partially implemented 2026-10-27: `CONFIG_EMB_OBJ_LIST` keeps the semaphore and mutex lists (`embk_sem_all`, `embk_mutex_all`) that init uses for KRN-OBJ-007; threads keep `CONFIG_EMB_THREAD_LIST`. `emb_obj_foreach()` and the debug descriptor export are not implemented. **Verification.** Test: debug descriptor consumer lists every object of the conformance run. **Trace.** SPEC-009 §7; OBS-008 diff --git a/docs/specs/SPEC-009-objects-capabilities-and-storage.md b/docs/specs/SPEC-009-objects-capabilities-and-storage.md index 7c65953..c4e42ac 100644 --- a/docs/specs/SPEC-009-objects-capabilities-and-storage.md +++ b/docs/specs/SPEC-009-objects-capabilities-and-storage.md @@ -136,7 +136,7 @@ Objects whose state never changes could live in flash; 1.0 does not support it ( UNINIT --init--> ACTIVE --destroy (no waiters, or ABORT_WAITERS: flush)--> DESTROYING --> UNINIT (generation + 1) ``` -- `init` on storage whose header reads `ACTIVE` is misuse in checked builds (`EMB_FAULT_API_LIFECYCLE`: double init) and `EMB_EEXIST` in release builds; storage must be zero or previously destroyed. `_DEFINE` storage is zero-initialized by the C runtime. +- Storage is reused only through `destroy` followed by `init`. `init` on storage that still holds an active object of its type is misuse in checked builds (`EMB_FAULT_API_LIFECYCLE`: double init) and `EMB_EBUSY` in release builds (amended by ADR-045). Reading the header alone cannot tell live storage from uninitialized storage that happens to hold the same bytes, so detection uses the kernel's own records: exact with the per-type lists of §7 (`CONFIG_EMB_OBJ_LIST`, and `CONFIG_EMB_THREAD_LIST` for threads); for tiny threads, from the type tag, active state and reserved diagnostic identity together (SPEC-008 §12). Without a list, `init` on such semaphore or mutex storage is undefined; storage must be never initialized or destroyed. `_DEFINE` storage is zero-initialized by the C runtime. - `destroy` follows ADR-020 and KRN-OBJ-001: with waiters and without `ABORT_WAITERS`, misuse (`EMB_EBUSY`); with it, every waiter wakes with `EMB_EDESTROYED` (SPEC-004 §6.7); type-specific rules apply (an owned mutex, a pool with outstanding blocks, a timer with a pending callback, a work queue with items, a running thread: all `EMB_EBUSY`). The object passes through `DESTROYING` so that a concurrent operation on another CPU observes `EMB_ESTATE` instead of racing, then becomes `UNINIT` with its generation bumped (KRN-OBJ-002) and its registry or table slots released. - **Freeze.** `emb_system_freeze()` (KRN-OBJ-004) sets a kernel flag; afterwards every `init`, `destroy`, `cap_grant`, `cap_derive`, `cap_delete`, and `cap_revoke` returns `EMB_EPERM` (fault in checked builds). Partition restart re-initializes a partition's objects through the kernel's own path, which the flag does not block. The isolated reference configuration calls it at the end of initialization; the `safety` profile attribute (ADR-035) requires it. - **Partition restart** (SPEC-010) bumps the generation of every object the partition owns and of the partition itself, re-initializes their storage from the image, and rebuilds the partition's capability table from the generated initial table; other partitions' capabilities to the restarted partition's objects fail with `EMB_ESTALE` until re-granted, and waiters on them are flushed with `STALE` (ADR-032, KRN-OBJ-005). @@ -144,7 +144,7 @@ UNINIT --init--> ACTIVE --destroy (no waiters, or ABORT_WAITERS: flush)--> DESTR ## 7. Names, registry, and tooling - `CONFIG_EMB_OBJ_NAMES` keeps the `name` pointer from the attribute; `emb_obj_name(handle)` returns it. Names are for debugging and trace; the kernel never compares them. -- `CONFIG_EMB_OBJ_LIST` links every active object into a per-type list; `emb_obj_foreach(type, cb, arg)` iterates under the scheduler lock. The debug descriptor (ADR-011) exports the list heads, the header offsets, the type tags, the registry (INDEXED), and the capability table layout (TABLE), so a debugger can list every object, its waiters, and its holders. +- `CONFIG_EMB_OBJ_LIST` links every active object into a per-type list from `init` to `destroy`, under the scheduler lock; `init` looks its storage up there (§6). Implemented for semaphores and mutexes; threads use `CONFIG_EMB_THREAD_LIST`. `emb_obj_foreach(type, cb, arg)`, iterating under the scheduler lock, is not yet implemented. The debug descriptor (ADR-011) exports the list heads, the header offsets, the type tags, the registry (INDEXED), and the capability table layout (TABLE), so a debugger can list every object, its waiters, and its holders. - Type tags are small integers, not ASCII words: the descriptor maps them to names. ## 8. Misuse and checked-build diagnostics @@ -154,7 +154,7 @@ UNINIT --init--> ACTIVE --destroy (no waiters, or ABORT_WAITERS: flush)--> DESTR | null handle; wrong type tag (POINTER); bad index, slot generation, or type (INDEXED, TABLE) | fault `EMB_FAULT_API_HANDLE` | `EMB_EINVAL` | | object generation mismatch (INDEXED, TABLE) | runtime condition | `EMB_ESTALE` | | missing right (TABLE) | fault `EMB_FAULT_API_HANDLE` for privileged callers | `EMB_EPERM` (always a status for unprivileged callers, KRN-CAP-002) | -| `init` on active storage | fault `EMB_FAULT_API_LIFECYCLE` | `EMB_EEXIST` | +| `init` on storage that holds an active object, where detected (§6) | fault `EMB_FAULT_API_LIFECYCLE` | `EMB_EBUSY` | | `destroy` with waiters, owner, outstanding blocks, pending callback, items, or a running thread | fault `EMB_FAULT_API_LIFECYCLE` | `EMB_EBUSY` | | operation on a `DESTROYING` object | runtime condition | `EMB_ESTATE` | | `init`, `destroy`, or capability change after freeze | fault `EMB_FAULT_API_LIFECYCLE` | `EMB_EPERM` | diff --git a/include/emb/mutex.h b/include/emb/mutex.h index 3ccdf1e..2682014 100644 --- a/include/emb/mutex.h +++ b/include/emb/mutex.h @@ -58,21 +58,25 @@ void emb_mutex_attr_default(emb_mutex_attr_t *out_attr); /** * emb_mutex_init() - Construct a mutex in caller-provided storage. - * @storage: Aligned caller-owned storage; retain it until successful destruction. + * @storage: Aligned caller-owned storage, never initialized or destroyed; retain it until + * successful destruction. * @attr: Attributes copied during initialization; NULL selects defaults. Keep name storage alive. * @out_mutex: Required output handle; cleared before argument validation. * * @ctx prekernel thread * @blocks no - * @time O(1) + * @time O(1); O(active mutexes) list lookup with CONFIG_EMB_OBJ_LIST * @owns borrows:storage * @config CONFIG_EMB_MUTEX - * @req API-020 API-021 KRN-OBJ-003 KRN-SYNC-003 KRN-SYNC-012 + * @req API-020 API-021 KRN-OBJ-003 KRN-OBJ-007 KRN-OBJ-011 KRN-SYNC-003 KRN-SYNC-012 * @since 0.2 * @stable yes * * Return: EMB_OK; EMB_EINVAL for bad storage/output, protocol or ceiling; - * EMB_ENOTSUP for INHERIT when CONFIG_EMB_MUTEX_INHERIT is off. + * EMB_ENOTSUP for INHERIT when CONFIG_EMB_MUTEX_INHERIT is off; EMB_EBUSY when + * @storage still holds a mutex that was not destroyed (misuse: storage is reused + * only through emb_mutex_destroy()). The refusal is exact with + * CONFIG_EMB_OBJ_LIST; without it such storage is undefined. */ emb_status_t emb_mutex_init(emb_mutex_storage_t *storage, const emb_mutex_attr_t *attr, emb_mutex_t *out_mutex); diff --git a/include/emb/sem.h b/include/emb/sem.h index af95aff..ae488a8 100644 --- a/include/emb/sem.h +++ b/include/emb/sem.h @@ -74,20 +74,24 @@ void emb_sem_attr_binary(emb_sem_attr_t *out_attr, bool initially_available); /** * emb_sem_init() - Construct a semaphore in caller-provided storage. - * @storage: Aligned caller-owned storage; retain it until successful destruction. + * @storage: Aligned caller-owned storage, never initialized or destroyed; retain it until + * successful destruction. * @attr: Copied attributes; NULL selects defaults. Keep name storage alive. * @out_sem: Required output handle; cleared before argument validation. * * @ctx prekernel thread * @blocks no - * @time O(1) + * @time O(1); O(active semaphores) list lookup with CONFIG_EMB_OBJ_LIST * @owns borrows:storage * @config CONFIG_EMB_SEM - * @req API-021 KRN-SYNC-028 KRN-OBJ-003 + * @req API-021 KRN-SYNC-028 KRN-OBJ-003 KRN-OBJ-007 KRN-OBJ-011 * @since 0.2 * @stable yes * - * Return: EMB_OK; EMB_EINVAL for bad storage/output, max == 0 or initial > max. + * Return: EMB_OK; EMB_EINVAL for bad storage/output, max == 0 or initial > max; + * EMB_EBUSY when @storage still holds a semaphore that was not destroyed + * (misuse: storage is reused only through emb_sem_destroy()). The refusal is + * exact with CONFIG_EMB_OBJ_LIST; without it such storage is undefined. */ emb_status_t emb_sem_init(emb_sem_storage_t *storage, const emb_sem_attr_t *attr, emb_sem_t *out_sem); diff --git a/include/emb/thread.h b/include/emb/thread.h index 130b5f4..42f4c17 100644 --- a/include/emb/thread.h +++ b/include/emb/thread.h @@ -92,7 +92,8 @@ void emb_thread_attr_default(emb_thread_attr_t *out_attr); * * @ctx prekernel thread * @blocks no - * @time bounded scan of at most 254 IDs + stack fill when configured + * @time bounded scan of at most 254 IDs + O(live threads) with CONFIG_EMB_THREAD_LIST + * + stack fill when configured * @owns borrows:storage borrows:attr->stack * @config * @req API-021 KRN-THR-004 KRN-THR-007 KRN-THR-015 KRN-THR-023 From 299204dd9838373e8002cf12705e399774538869 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 17:56:53 +0100 Subject: [PATCH 03/30] docs(obj): add the local RV32, AVR and EmbSIM runs to the lifecycle report The report listed AVR, RV32 and the EmbSIM suites as not run locally. They now ran: RV32 and AVR under QEMU and the three EmbSIM boards all pass. On avr-uno-t1 the kernel code is identical to main's with the same compiler. The local avr-gcc 14.4 measures 4,126 bytes of kernel text on both, above the 4,096-byte gate that CI measures with gcc-avr 7.3, so the report states which compiler the gate applies to. Signed-off-by: teo1747 --- docs/engineering/m2-02-object-lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/engineering/m2-02-object-lifecycle.md b/docs/engineering/m2-02-object-lifecycle.md index 124576f..d386799 100644 --- a/docs/engineering/m2-02-object-lifecycle.md +++ b/docs/engineering/m2-02-object-lifecycle.md @@ -63,4 +63,4 @@ Each case blocks a waiter on the live object (and, for the mutex, owns it), atte The strict documentation build could only be approximated locally, with Doxygen 1.18.0 standing in for the pinned 1.15.0: it reports the same seven warnings in generated API pages for unchanged headers on `main` (`3bd0cf7`) as on this change, so the documentation job's pinned toolchain is authoritative here. -Not run locally: AVR, RV32 and the EmbSIM board jobs, whose toolchains or simulator versions are unavailable on this machine; they run in CI. No hardware is involved. The SMP `DESTROYING` state of KRN-OBJ-007 remains unimplemented on uniprocessor builds. +Later on the same machine, with the editor-setup branch's test fix merged in: RV32 under QEMU (rv32-virt, -release, -tiny: 12/12 each; Homebrew riscv64-elf-gcc 16.2 with a locally built picolibc 1.8.10), AVR under QEMU (avr-uno, avr-uno-release: 12/12; avr-uno-t1: 11/11; Homebrew avr-gcc 14.4.0) and the EmbSIM board suites (avr-uno-embsim 12/12, cm4-nucleo-embsim 13/13, rv32-c3-embsim 12/12, each with EmbSIM built at the reference CI pins). On avr-uno-t1 the kernel archive's instructions and per-object sizes are identical to `main`'s built with the same compiler: kernel RAM 64 bytes and TCB 32 bytes on both. Its kernel text, 4,126 bytes for both, exceeds the 4,096-byte gate only because avr-gcc 14.4 generates larger code than the gcc-avr 7.3 the gate is defined with; CI measures the gate. No hardware is involved. The SMP `DESTROYING` state of KRN-OBJ-007 remains unimplemented on uniprocessor builds. From c61339dfc47c69e97502e9bd978a6413a1a4d3b6 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 17:58:26 +0100 Subject: [PATCH 04/30] docs(m2-01): close the seed-5 report as not reproducible The historical cancel_waiter_disinherits seed-5 report named no binary, placement or output. Bounded triage found no divergence in 56,000 current-source runs and none in 4,200 runs of the rebuilt reporting source, where the seed-3 control still failed as expected. The four classifications in timing-and-evidence.md assume a recoverable input, so the item could never be checked off. timing-and-evidence.md now defines a separate disposition for such reports: "not reproducible: input unavailable, cause unknown". It is neither a classification nor a fix. It requires a recorded bounded attempt, named reopen triggers, and continued listing among the known limits. The project owner applied it to this report on 2026-10-27. The M2-01 checklist item is checked. The triage, investigation, timer-correction and evidence-workflow pages, STATUS, the model README and the changelog now state the disposition and the reopen triggers. Dated evidence records keep their original wording. Signed-off-by: teo1747 --- CHANGELOG.md | 2 ++ docs/STATUS.md | 4 ++-- docs/engineering/differential-evidence.md | 2 +- docs/engineering/m2-01-investigation.md | 2 +- docs/engineering/m2-01-seed5-triage.md | 8 +++++--- docs/engineering/m2-01-timer-correction.md | 2 +- docs/engineering/m2-checklist.md | 4 ++-- docs/engineering/timing-and-evidence.md | 2 ++ tools/model/README.md | 2 +- 9 files changed, 17 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 936911a..df3ec33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Changed +- The historical M2-01 `cancel_waiter_disinherits` seed-5 report is closed as not reproducible (input unavailable, cause unknown) after bounded triage: not fixed, not classified, still listed among the known limits, with named reopen triggers. `timing-and-evidence.md` defines that disposition. + - `emb_sem_init()` and `emb_mutex_init()` refuse storage that still holds an active object of their type (fault in checked builds, `EMB_EBUSY` in release) instead of overwriting it, when `CONFIG_EMB_OBJ_LIST` is on (default except on the tiny profile). Storage is reused only through destroy. Semaphore and mutex storage grows by one list node where the option is on. KRN-OBJ-007 now specifies `EMB_EBUSY` instead of `EMB_EEXIST` (ADR-045). ### Added diff --git a/docs/STATUS.md b/docs/STATUS.md index 8ac9495..8e7ca86 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -37,7 +37,7 @@ The [M2-01 timer correction](engineering/m2-01-timer-correction.md), source `ca0 The [differential evidence workflow](engineering/differential-evidence.md), source `8d7bca4`, adds automatic failure/probe bundles, offline/live replay and bounded confirmed schedule reduction, including full resolved-configuration checks independent of checkout paths. It passed 67 model/tooling tests, twelve CTest targets per checked/release/tiny/periodic native configuration and another 11,200 deep-campaign runs with zero divergences. Its workflow checks deliberately create search-budget failures to validate capture/replay, and keep those separate from actual kernel evidence. Native CI is configured to retain default failure bundles on failed jobs. -The [bounded seed-5 triage](engineering/m2-01-seed5-triage.md), current source `37887c9`, subsequently passed 56,000 unfiltered executions across seeds 1–10 on those four presets, with the existing applicability exclusions. Rebuilding the source that first recorded the report reproduced the expected old seed-3 mismatch (1399/1400 agree) and passed 4,200 seed-5 runs on checked/release/tiny. This retains the historical cancellation finding as unconfirmed with unknown cause, not fixed. Commands, bounds, source/build/configuration identities, logs and exact current cancellation schedules are archived separately. +The [bounded seed-5 triage](engineering/m2-01-seed5-triage.md), current source `37887c9`, subsequently passed 56,000 unfiltered executions across seeds 1–10 on those four presets, with the existing applicability exclusions. Rebuilding the source that first recorded the report reproduced the expected old seed-3 mismatch (1399/1400 agree) and passed 4,200 seed-5 runs on checked/release/tiny. The historical cancellation report was subsequently closed as not reproducible (input unavailable, cause unknown; maintainer decision 2026-10-27), not fixed and not classified. Commands, bounds, source/build/configuration identities, logs and exact current cancellation schedules are archived separately. The [EmbCC review](engineering/embcc-capability-review.md), compiler source `a3c3ef4` and RTOS source `2e59c2a`, confirms upstream implementation of the ten former compiler gaps within their stated restrictions. Ten focused local checks passed; AVR linking/program-memory fixtures and small RV32/AVR atomic values executed under EmbSim. No local QEMU, board, live-debug, CMSE-crossing or complete EmbCC RTOS conformance run is claimed. The host compiler bootstrap required local flags and was not warning-clean. Linux executable configure lacked a built target runtime; compile-only configure succeeded but the RTOS storage probe failed on the missing EmbCC `EMB_LAYOUT_MARKER`. Upstream PR #119 reports RTOS experiments with an injected marker, recorded separately from current CI qualification. @@ -65,7 +65,7 @@ The [EmbCC layout-marker correction](engineering/embcc-layout-marker.md), source - Hardware timing and total memory use remain to be demonstrated on the primary Cortex-M board. Map-derived kernel subtotals exclude port, board, runtime and stack costs. - The Cortex-M base TCB figures in the port page exceed R-003 T4's target; that is a disclosed target miss, not a failed native setup. -- The [M2-01 timer correction](engineering/m2-01-timer-correction.md) addresses the reproduced seed-3 model timing gap, and the [evidence workflow](engineering/differential-evidence.md) provides automatic bundles, exact replay and bounded schedule reduction. The [bounded triage](engineering/m2-01-seed5-triage.md) leaves the historical seed-5 cancellation finding unconfirmed without its original schedule/build. Passing current campaigns does not establish the historical cause or close all of M2-01. +- The [M2-01 timer correction](engineering/m2-01-timer-correction.md) addresses the reproduced seed-3 model timing gap, and the [evidence workflow](engineering/differential-evidence.md) provides automatic bundles, exact replay and bounded schedule reduction. The historical seed-5 cancellation report, whose original schedule and build are unavailable, is closed as **not reproducible: input unavailable, cause unknown** after the [bounded triage](engineering/m2-01-seed5-triage.md). It remains a known limit: passing current campaigns does not establish its cause, and the triage names the evidence that would reopen it. - AVR launch/restore uses `out SREG; pop r0; ret`; pending IRQ coherence during that tail remains a physical validation obligation (A27), not a verified atomic out/ret pair. - Embedded fault-expectation cases that require host `fork` are skipped. A passing emulator job does not establish those misuse paths on hardware. - The [M2-02 IRQ correction](engineering/m2-02-irq-priority.md) implements larger-is-more-urgent in the advertised kernel-aware domain: Cortex-M derives the range/encoding from board priority width and BASEPRI; native, AVR and current RV32 expose level 0 only. Invalid requests fault when checked or return an error in release before controller writes. SysTick defaults to highest kernel-aware urgency. Initial QEMU M3/M4 and F446 EmbSIM runs passed the new register/order/preemption cases; local checked/release/tiny/periodic native and experimental EmbCC-kernel/GCC-host checked builds each passed 13 CTest targets. Physical latency/jitter, raw kernel-independent vectors and generic hardware-description generation remain unqualified/unimplemented; the raw class is refused at configuration. diff --git a/docs/engineering/differential-evidence.md b/docs/engineering/differential-evidence.md index a505f72..1fe7e40 100644 --- a/docs/engineering/differential-evidence.md +++ b/docs/engineering/differential-evidence.md @@ -75,4 +75,4 @@ Every applicable native CTest build registers `differential_evidence`, bringing On source `8d7bca4cfc7c2d667dd9c1d7f33cd72322db982f`, local validation passed 67 model/tooling tests with Hypothesis, all twelve CTest targets on checked/release/tiny/periodic native builds, and both unfiltered 40-schedule campaigns on all four configurations (11,200 runs, zero divergences, existing exclusions retained). Unit tests cover ordered scenario round trips, offline failure/agreement outcomes, partial timeout output, launch/exit/malformed/lost-event distinctions, full configuration drift/checkout relocation, invalid bundle data, overwrite protection, repeat confirmation, changed/flaky failure predicates, budgets and mixed identities. Additional CLI checks exercised probe capture and an unwritable capture destination. -The [verification record](https://github.com/EmbLinkOs/EmbLinkRTOS/tree/main/tools/model/evidence/workflow-checks) preserves these logs and source/binary/configuration fingerprints. Production kernel, architecture and model state-transition sources are unchanged by this tooling work. General scenario/program reduction and recovery/classification of the original seed-5 finding remain separate work. +The [verification record](https://github.com/EmbLinkOs/EmbLinkRTOS/tree/main/tools/model/evidence/workflow-checks) preserves these logs and source/binary/configuration fingerprints. Production kernel, architecture and model state-transition sources are unchanged by this tooling work. General scenario/program reduction remains separate work. The original seed-5 report was closed as not reproducible (input unavailable, cause unknown) after the [bounded triage](m2-01-seed5-triage.md). diff --git a/docs/engineering/m2-01-investigation.md b/docs/engineering/m2-01-investigation.md index 53a3aa6..ecaa29a 100644 --- a/docs/engineering/m2-01-investigation.md +++ b/docs/engineering/m2-01-investigation.md @@ -114,4 +114,4 @@ Items 1–3 below are addressed by the [timer-delivery correction](m2-01-timer-c 1. Represent timer request and delivery at critical-section boundaries explicitly. Preserve deadlines fixed at the call, deadline-order expiry, wait-generation invariants and P1 scheduling. An already-due arm must eventually deliver a timer interrupt even without another injected tick. 2. Add regression checks for the two-sleeper ordering and original suspension case, plus already-due arming with no later tick, wake-in-window races and ordinary future deadlines. Keep periodic and tickless applicability explicit. 3. Replay the archived inputs, run model/conformance checks and repeat both unfiltered campaigns on checked, release and tiny. Record changed source/configuration identities and actual outcomes. -4. Add reusable failure capture/replay tooling and retain the historical seed-5 item until it has a concrete explanation. M2-01's completed capture work does not close these remaining steps. +4. Add reusable failure capture/replay tooling and retain the historical seed-5 item until it has a concrete explanation. M2-01's completed capture work does not close these remaining steps. *Outcome:* the tooling exists; after the [bounded triage](m2-01-seed5-triage.md) the seed-5 report was closed as not reproducible (input unavailable, cause unknown) on 2026-10-27, without a classification. diff --git a/docs/engineering/m2-01-seed5-triage.md b/docs/engineering/m2-01-seed5-triage.md index 548453e..390c6cb 100644 --- a/docs/engineering/m2-01-seed5-triage.md +++ b/docs/engineering/m2-01-seed5-triage.md @@ -1,6 +1,6 @@ # M2-01: Bounded historical seed-5 triage -**Evidence date:** 2026-10-14. **Current source:** `37887c9e92e2242ff4d11cfa58c6972fce4b0e0e`. **Disposition:** the historical `cancel_waiter_disinherits` seed-5 report remains unconfirmed and its cause is unknown. No cancellation/disinheritance correction is claimed. The bounded investigation is complete; the historical finding remains an unresolved evidence item. +**Evidence date:** 2026-10-14. **Current source:** `37887c9e92e2242ff4d11cfa58c6972fce4b0e0e`. **Disposition:** the historical `cancel_waiter_disinherits` seed-5 report is closed as **not reproducible: input unavailable, cause unknown** ([timing and evidence](timing-and-evidence.md); maintainer decision, 2026-10-27). No cancellation/disinheritance correction is claimed and no classification is made. The bounded investigation is complete; the report stays listed among the known limits. The [initial investigation](m2-01-investigation.md) recovered seed 3, the [timer correction](m2-01-timer-correction.md) addressed that model gap, and the [capture workflow](differential-evidence.md) now preserves future failures. This follow-up broadens the captured campaign and checks the source that first recorded the report. It changes no kernel, architecture, runner or model implementation. @@ -59,6 +59,8 @@ These individual commands preserve the input parameters; they do not by themselv ## Remaining uncertainty and next gate -Keep the historical finding open with the explicit disposition **unconfirmed; original input/build unavailable; root cause unknown**. Reopen reproduction work when an original trace, encoded scenario, placement/build record or a new captured cancellation failure is available. A new failure must be replayed, reduced within recorded budgets and classified against the contracts; do not assume it shares the seed-3 cause. +The original input and build cannot be recovered, and the bounded campaigns above found no current divergence. On 2026-10-27 the project owner closed the report as **not reproducible: input unavailable, cause unknown**. This closes the M2-01 checklist item; it does not classify the report or claim it fixed. -The reproduced seed-3 correction, capture tooling and this bounded triage can be reviewed independently. M2-01's historical classification checkbox remains unchecked. Read-only [M2-02 contract preparation](m2-02-contract-review.md) can proceed while retaining this limitation; that is not a release waiver or a declaration that all M2-01 evidence is complete. Subsequent coding sessions must keep the unresolved finding visible and retain the diagnostic capture/regression checks. +Reopen when any of these appears: an original trace, encoded scenario, placement or build record for the report; or a new captured cancellation failure. A new failure must be replayed, reduced within recorded budgets and classified against the contracts; do not assume it shares the seed-3 cause. The automatic failure bundles of the [evidence workflow](differential-evidence.md), the `differential_bridge`, `differential_timer_regressions` and `differential_evidence` checks and the archived regressions stay in place to catch one. + +The report stays listed among the known limits in [Project status](../STATUS.md) and in the notes of any release that includes the affected code. Closing it is not a release waiver for any other required failure. diff --git a/docs/engineering/m2-01-timer-correction.md b/docs/engineering/m2-01-timer-correction.md index e8dc52a..be971ad 100644 --- a/docs/engineering/m2-01-timer-correction.md +++ b/docs/engineering/m2-01-timer-correction.md @@ -59,4 +59,4 @@ python3 -m unittest tests.test_timer_delivery.ArchivedReplays -v ## Remaining M2-01 scope -The reproduced seed-3/model timing defect has a correction and automated regressions. The subsequent [evidence workflow](differential-evidence.md) implements general automatic bundles and bounded schedule reduction. Recovering and classifying the historical seed-5 input remains open. A related cancellation trace agreeing after this correction does not establish that the historical input had the same cause. Continue that work before marking all of M2-01 complete. +The reproduced seed-3/model timing defect has a correction and automated regressions. The subsequent [evidence workflow](differential-evidence.md) implements general automatic bundles and bounded schedule reduction. The historical seed-5 input could not be recovered and was closed as not reproducible (input unavailable, cause unknown) after the [bounded triage](m2-01-seed5-triage.md). A related cancellation trace agreeing after this correction does not establish that the historical input had the same cause. diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index a813dda..32a6cdb 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -24,10 +24,10 @@ Items 5–8 can be developed independently once their common prerequisites are s ## M2-01: Reproducible failure capture first -The [initial investigation](m2-01-investigation.md) archives the seed-3 reproduction and a two-sleeper reduction. The [timer-delivery correction](m2-01-timer-correction.md) adds model/live regressions and records 11,200 agreeing deep-campaign runs. The [evidence workflow](differential-evidence.md) now provides automatic bundles, exact offline/live replay and bounded schedule reduction with repeat confirmation. The [bounded seed-5 triage](m2-01-seed5-triage.md) records 56,000 current-source agreements and a historical-source check: the old seed-3 control fails as expected, but seed 5 passes. The historical seed-5 cause remains unknown; M2-01 is not complete. +The [initial investigation](m2-01-investigation.md) archives the seed-3 reproduction and a two-sleeper reduction. The [timer-delivery correction](m2-01-timer-correction.md) adds model/live regressions and records 11,200 agreeing deep-campaign runs. The [evidence workflow](differential-evidence.md) now provides automatic bundles, exact offline/live replay and bounded schedule reduction with repeat confirmation. The [bounded seed-5 triage](m2-01-seed5-triage.md) records 56,000 current-source agreements and a historical-source check: the old seed-3 control fails as expected, but seed 5 passes. The original seed-5 input and build cannot be recovered; the project owner closed the report as not reproducible (input unavailable, cause unknown) on 2026-10-27. The cause remains unknown and the report stays a disclosed limit. M2-01 is complete. - [x] Capture `suspend_while_waiting` from the unfiltered 40-schedule campaign with seed 3. -- [ ] Recover/capture and classify the historical `cancel_waiter_disinherits` seed-5 input; the current documented campaign passes without reproducing it. +- [x] Recover/capture and classify the historical `cancel_waiter_disinherits` seed-5 input; the current documented campaign passes without reproducing it. Closed as **not reproducible: input unavailable, cause unknown** (maintainer decision 2026-10-27; disposition defined in [timing and evidence](timing-and-evidence.md)): not classified, not fixed; reopen triggers in the [triage](m2-01-seed5-triage.md). - [x] Complete bounded seeds 1–10 campaigns on checked/release/tiny/periodic and check the report's originating source; retain the historical finding as unconfirmed and identify evidence that would reopen reproduction work. - [x] Archive observed runner/model source identity, profile/check/owner-death settings, encoded scenarios, exact IRQ/tick placement, complete traces, operation results and final states. - [x] Replay the captured schedules exactly; do not assume `--only` preserves an unfiltered random schedule. diff --git a/docs/engineering/timing-and-evidence.md b/docs/engineering/timing-and-evidence.md index 1eaf232..cc5514f 100644 --- a/docs/engineering/timing-and-evidence.md +++ b/docs/engineering/timing-and-evidence.md @@ -66,6 +66,8 @@ The model explores interleavings at section granularity for finite scenario prog For deep replay failures, save the exact runner/model build, full trace, scenario, seed and interrupt/tick schedule; minimize the case and classify it as a kernel defect, model defect, bridge limitation or unsupported scenario with a regression test when resolved. Keep unresolved cases visible in release notes and [Project status](../STATUS.md). +A historical report whose input and build cannot be recovered cannot be classified. After a bounded, recorded reproduction attempt, a maintainer may close it with the disposition **not reproducible: input unavailable, cause unknown**. This is not one of the four classifications, not a fix, and not evidence that the reported behavior cannot occur. The closing record states the attempt's scope, names the triggers that reopen it, and keeps the report listed among the known limits in Project status and release notes. + ## Memory and reproducibility evidence Report kernel, architecture, board, application and runtime subtotals alongside the complete linked flash image. Include `.data` initialization images in flash, live `.data`/`.bss`, alignment/padding, metadata and retained sections. Distinguish reserved from loaded sections. diff --git a/tools/model/README.md b/tools/model/README.md index 5fcc7b3..1ce6280 100644 --- a/tools/model/README.md +++ b/tools/model/README.md @@ -66,7 +66,7 @@ Condition variables, event flags, barriers, and message queues are added when th The model/tooling suite (67 tests after the evidence workflow), six-schedule bridge campaign, exact timer regressions and evidence workflow check are separate checks. Exhaustive exploration enumerates the enabled interleavings of the finite scenarios supplied to it, within the recorded state budget; it is not a proof of every program with a given thread/object count or of C memory safety, SMP or hardware behavior. Random walks that reach their step limit are bounded invariant checks and must not be reported as completed terminal executions. See `docs/engineering/timing-and-evidence.md` and `docs/STATUS.md`. -The historical seed-5 finding remains open. Original failure inputs/traces/identities are in `evidence/m2-01/`; corrected-source outcomes are in `evidence/m2-01-correction/`. The full diagnostic campaigns start from the repository root with the built native runner: +The historical seed-5 report is closed as not reproducible (input unavailable, cause unknown; see `docs/engineering/m2-01-seed5-triage.md` for the reopen triggers). Original failure inputs/traces/identities are in `evidence/m2-01/`; corrected-source outcomes are in `evidence/m2-01-correction/`. The full diagnostic campaigns start from the repository root with the built native runner: The subsequent [bounded seed-5 triage](../../docs/engineering/m2-01-seed5-triage.md) records 56,000 agreeing current-source runs across seeds 1–10 on checked/release/tiny/periodic, plus a rebuild of the source that introduced the historical report. That archive reproduces the known seed-3 mismatch while seed 5 passes on three presets. `evidence/seed5-triage/` preserves commands, identities, configurations, logs and exact current cancellation schedules. The historical cancellation input remains unconfirmed; no disinheritance fix is inferred. The commands below repeat the earlier two-seed diagnostic campaigns: From fb953674cfcd9cf976749cde8b61bed8467c5beb Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 18:00:23 +0100 Subject: [PATCH 05/30] tools(footprint): take the TCB size from the layout probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footprint gate's per-thread figure was the size of the embk_idle_thread symbol, which equals sizeof(embk_thread_t) only while idle is a full control block. Slimming the tiny idle context, the next RAM-headroom step, would have turned tcb_max into a measurement of the idle record instead of the application TCB. --storage-h reads EMB_THREAD_STORAGE_SIZE from the build's generated , the layout probe that SPEC-012 §13 names as the source of the TCB size. The idle symbol is reported separately as the idle context, which counts in kernel static RAM. Without --storage-h the old symbol-based figure is kept and labelled as such. The JSON report gains tcb_source and idle_context. The Cortex-M, RISC-V and AVR footprint steps pass --storage-h, and tools/footprint/test_footprint.py (map attribution, header parsing, thresholds, missing size) runs in the lint job. Same figures as before on cm4-an386 (TCB 120), rv32-virt (120) and avr-uno-t1 (32, kernel RAM 64). Signed-off-by: teo1747 --- .github/workflows/ci.yml | 5 ++ docs/ports/avr.md | 2 +- tools/footprint/footprint.py | 39 ++++++++++--- tools/footprint/test_footprint.py | 94 +++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 tools/footprint/test_footprint.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 923af32..027b1b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,7 @@ jobs: python3 -I tools/footprint/footprint.py \ build/${{ matrix.preset }}/samples/first_execution/first_execution.elf.map \ --elf build/${{ matrix.preset }}/samples/first_execution/first_execution.elf --nm arm-none-eabi-nm \ + --storage-h build/${{ matrix.preset }}/generated/include/emb/storage.h \ --title "${{ matrix.preset }}" --json footprint-${{ matrix.preset }}.json | tee footprint-${{ matrix.preset }}.md - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -115,6 +116,7 @@ jobs: python3 -I tools/footprint/footprint.py \ build/${{ matrix.preset }}/samples/first_execution/first_execution.elf.map \ --elf build/${{ matrix.preset }}/samples/first_execution/first_execution.elf --nm riscv64-unknown-elf-nm \ + --storage-h build/${{ matrix.preset }}/generated/include/emb/storage.h \ --title "${{ matrix.preset }}" --json footprint-${{ matrix.preset }}.json | tee footprint-${{ matrix.preset }}.md - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -150,6 +152,7 @@ jobs: python3 -I tools/footprint/footprint.py \ build/${{ matrix.preset }}/samples/${{ matrix.sample }}/${{ matrix.sample }}.elf.map \ --elf build/${{ matrix.preset }}/samples/${{ matrix.sample }}/${{ matrix.sample }}.elf --nm avr-nm \ + --storage-h build/${{ matrix.preset }}/generated/include/emb/storage.h \ --title "${{ matrix.preset }}" --json footprint-${{ matrix.preset }}.json \ --thresholds tools/footprint/thresholds-${{ matrix.preset }}.json | tee footprint-${{ matrix.preset }}.md - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -181,6 +184,8 @@ jobs: run: python3 -I -m unittest discover -s tools/toolchain -p 'test_*.py' -v - name: ESP32-C3 image format run: python3 -I -m unittest discover -s tools/esp -p 'test_*.py' -v + - name: Footprint report tool + run: python3 -I -m unittest discover -s tools/footprint -p 'test_*.py' -v - name: Configuration tool self-check run: | python3 -I tools/kconfig/embconfig.py --kconfig Kconfig --manifest arch/native/arch.yaml \ diff --git a/docs/ports/avr.md b/docs/ports/avr.md index 3b93fc4..9e5fd5d 100644 --- a/docs/ports/avr.md +++ b/docs/ports/avr.md @@ -50,7 +50,7 @@ Periodic tick on Timer1 CTC: prescaler `CONFIG_EMB_AVR_TIMER1_PRESCALER` (64), ` ## Footprint (avr-gcc 7.3 `-Os -mrelax -mcall-prologues`, tiny profile) -Measured with `tools/footprint/footprint.py` from the link map; the kernel total counts the `kernel/` objects only. The R-003 reference is `samples/footprint_t1` under `configs/footprint-t1.conf` (the T1 feature set: scheduler, notifications, sleep and timeouts, one semaphore; no mutex, no trace), gated per commit on the `avr-uno-t1` preset with the targets as hard limits. The two `first_execution` rows link the mutex as well and are gated at their baseline plus 10 percent. +Measured with `tools/footprint/footprint.py` from the link map; the kernel total counts the `kernel/` objects only. The TCB figure is `EMB_THREAD_STORAGE_SIZE` from the build's generated `` (the layout probe, SPEC-012 §13); the idle context is reported beside it and counts in kernel static RAM. The R-003 reference is `samples/footprint_t1` under `configs/footprint-t1.conf` (the T1 feature set: scheduler, notifications, sleep and timeouts, one semaphore; no mutex, no trace), gated per commit on the `avr-uno-t1` preset with the targets as hard limits. The two `first_execution` rows link the mutex as well and are gated at their baseline plus 10 percent. | preset | program | kernel text | kernel static RAM | thread control block | R-003 target | |---|---|---:|---:|---:|---| diff --git a/tools/footprint/footprint.py b/tools/footprint/footprint.py index e19f2da..6c09442 100644 --- a/tools/footprint/footprint.py +++ b/tools/footprint/footprint.py @@ -5,7 +5,10 @@ Reads the input-section lines of a map file, attributes each to the object file that contributed it, and sums text, data and bss per component (kernel module, port, board, -application, toolchain runtime). Optionally checks the sums against a thresholds file: +application, toolchain runtime). The per-thread control block size comes from the +generated of the same build (EMB_THREAD_STORAGE_SIZE, the layout probe, +SPEC-012 §13); the idle context is reported separately, as it is kernel static RAM and +need not be a full control block. Optionally checks the sums against a thresholds file: {"kernel_text_max": 4096, "kernel_ram_max": 64, "tcb_max": 32} @@ -86,14 +89,24 @@ def parse_map(path: str): return sums -def tcb_size(elf: str, nm: str): +STORAGE_SIZE = re.compile(r"^#define\s+EMB_THREAD_STORAGE_SIZE\s+(\d+)u?\s*$", re.MULTILINE) + + +def thread_storage_size(storage_h: str): + """sizeof(embk_thread_t) as the layout probe measured it for this build.""" + with open(storage_h, encoding="utf-8") as f: + m = STORAGE_SIZE.search(f.read()) + return int(m.group(1)) if m else None + + +def symbol_size(elf: str, nm: str, name: str): try: out = subprocess.run([nm, "--print-size", elf], check=True, capture_output=True, text=True).stdout except (OSError, subprocess.CalledProcessError): return None for line in out.splitlines(): p = line.split() - if len(p) == 4 and p[3] == "embk_idle_thread": + if len(p) == 4 and p[3] == name: return int(p[1], 16) return None @@ -101,8 +114,9 @@ def tcb_size(elf: str, nm: str): def main(argv): ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("map", help="GNU ld map file (-Wl,-Map)") - ap.add_argument("--elf", help="the image, for the control block size") + ap.add_argument("--elf", help="the image, for the idle context size") ap.add_argument("--nm", default="nm") + ap.add_argument("--storage-h", help="the build's generated emb/storage.h, for the control block size") ap.add_argument("--thresholds", help="JSON thresholds file") ap.add_argument("--json", help="write the report as JSON here") ap.add_argument("--title", default="") @@ -116,7 +130,15 @@ def main(argv): if comp.startswith("kernel/"): for k in kernel: kernel[k] += v[k] - tcb = tcb_size(args.elf, args.nm) if args.elf else None + idle = symbol_size(args.elf, args.nm, "embk_idle_thread") if args.elf else None + if args.storage_h: + tcb = thread_storage_size(args.storage_h) + if tcb is None: + print(f"footprint: no EMB_THREAD_STORAGE_SIZE in {args.storage_h}", file=sys.stderr) + return 2 + tcb_source = "EMB_THREAD_STORAGE_SIZE" + else: + tcb, tcb_source = idle, "embk_idle_thread" # earlier reports: idle was a full control block print(f"# Footprint {args.title}".rstrip()) print() print("| component | text | data | bss |") @@ -127,9 +149,12 @@ def main(argv): print() print(f"Kernel static RAM (data + bss): {kernel['data'] + kernel['bss']} bytes") if tcb is not None: - print(f"Thread control block (embk_idle_thread): {tcb} bytes") + print(f"Thread control block ({tcb_source}): {tcb} bytes") + if idle is not None and args.storage_h: + print(f"Idle context (embk_idle_thread, in kernel static RAM): {idle} bytes") report = {"components": {c: {"text": t, "data": d, "bss": b} for c, t, d, b in rows}, - "kernel": kernel, "kernel_ram": kernel["data"] + kernel["bss"], "tcb": tcb} + "kernel": kernel, "kernel_ram": kernel["data"] + kernel["bss"], "tcb": tcb, + "tcb_source": tcb_source, "idle_context": idle} if args.json: with open(args.json, "w", encoding="utf-8") as f: json.dump(report, f, indent=2) diff --git a/tools/footprint/test_footprint.py b/tools/footprint/test_footprint.py new file mode 100644 index 0000000..531e4ee --- /dev/null +++ b/tools/footprint/test_footprint.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Junior Deogracias +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import footprint # noqa: E402 + +MAP = """\ +Archive member included to satisfy reference by file (symbol) + +Linker script and memory map + + .text 0x00000000 0x40 kernel/libemb_kernel.a(sched.c.obj) + .text.emb_thread_init + 0x00000040 0x20 kernel/libemb_kernel.a(thread.c.obj) + .progmem.data 0x00000060 0x10 kernel/libemb_kernel.a(word.c.obj) + .text 0x00000070 0x30 arch/avr/libemb_arch.a(avr_port.c.obj) + .text 0x000000a0 0x08 samples/footprint_t1/CMakeFiles/footprint_t1.dir/main.c.obj + .data 0x00800100 0x2 kernel/libemb_kernel.a(fault.c.obj) + .bss.embk_idle_thread + 0x00800102 0x20 kernel/libemb_kernel.a(sched.c.obj) + .bss 0x00800122 0x4 arch/avr/libemb_arch.a(avr_context.c.obj) + .bss 0x00800126 0x6 kernel/libemb_kernel.a(exec.c.obj) + .debug_info 0x00000000 0x100 kernel/libemb_kernel.a(sched.c.obj) +OUTPUT(footprint_t1.elf elf32-avr) +""" + +STORAGE_H = """\ +#define EMB_SEM_STORAGE_SIZE 12u +#define EMB_THREAD_STORAGE_SIZE 32u +#define EMB_THREAD_STORAGE_ALIGN 1u +""" + + +class Footprint(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.addCleanup(self.dir.cleanup) + + def write(self, name, text): + path = os.path.join(self.dir.name, name) + with open(path, "w", encoding="utf-8") as f: + f.write(text) + return path + + def run_main(self, *args): + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()): + rc = footprint.main(list(args)) + return rc, out.getvalue() + + def test_map_attributes_sections_to_components(self): + sums = footprint.parse_map(self.write("a.map", MAP)) + self.assertEqual(sums["kernel/sched"], {"text": 0x40, "data": 0, "bss": 0x20}) + self.assertEqual(sums["kernel/thread"]["text"], 0x20) + self.assertEqual(sums["kernel/word"]["text"], 0x10) # flash tables count as text + self.assertEqual(sums["kernel/fault"]["data"], 2) + self.assertEqual(sums["arch"], {"text": 0x30, "data": 0, "bss": 4}) + self.assertEqual(sums["app"]["text"], 8) + self.assertNotIn("debug", "".join(sums)) + + def test_control_block_comes_from_the_storage_header(self): + self.assertEqual(footprint.thread_storage_size(self.write("s.h", STORAGE_H)), 32) + self.assertIsNone(footprint.thread_storage_size(self.write("t.h", "#define X 1u\n"))) + + def test_report_and_thresholds(self): + m, h = self.write("a.map", MAP), self.write("s.h", STORAGE_H) + report = os.path.join(self.dir.name, "r.json") + ok = self.write("ok.json", json.dumps({"kernel_text_max": 0x70, "kernel_ram_max": 40, "tcb_max": 32})) + rc, out = self.run_main(m, "--storage-h", h, "--json", report, "--thresholds", ok) + self.assertEqual(rc, 0, out) + with open(report, encoding="utf-8") as f: + r = json.load(f) + self.assertEqual(r["kernel"], {"text": 0x70, "data": 2, "bss": 0x26}) + self.assertEqual((r["kernel_ram"], r["tcb"], r["tcb_source"]), (40, 32, "EMB_THREAD_STORAGE_SIZE")) + tight = self.write("tight.json", json.dumps({"kernel_ram_max": 39, "tcb_max": 31})) + rc, out = self.run_main(m, "--storage-h", h, "--thresholds", tight) + self.assertEqual(rc, 1) + self.assertIn("kernel_ram_max = 39, measured 40", out) + self.assertIn("tcb_max = 31, measured 32", out) + + def test_storage_header_without_the_size_is_an_error(self): + rc, _ = self.run_main(self.write("a.map", MAP), "--storage-h", self.write("t.h", "\n")) + self.assertEqual(rc, 2) + + +if __name__ == "__main__": + unittest.main() From db96a6bd89b0f6f3e525021b275b242ef3ea98f9 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 18:11:39 +0100 Subject: [PATCH 06/30] perf(tiny): allocate only the prefix of the idle control block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-003 T2 counts the idle context in the tiny kernel's fixed 64 bytes, and footprint_t1 sat at that limit with a full 32-byte control block for idle. Idle never blocks, times out, takes notifications, joins or changes priority. The scheduler, the switch path and diagnostics read only its leading fields: arch, obj, the priority/state bytes, tflags, cancel_disable, the index where not packed, and lock_depth in checked builds. The idle context now has its own type tag, EMBK_OBJ_IDLE, on every profile, so no handle resolves to it. emb_thread_self() returns EMB_HANDLE_NULL in the idle context; before, it handed out a handle that emb_notify_set() and the queries accepted. With the table scheduler and no idle thread, kernel/sched/idle_context.c allocates only the prefix in front of wait_gen, as an aligned byte array. Every other translation unit still declares a full embk_thread_t, so the compiler never sees an access through a smaller object; static assertions pin the fields used. Idle initialization clears only the prefix, and the checked stack-guard check skips the idle context. Ports already touch only idle->arch. A prefix-sized object reached through a cast was rejected: GCC 14's -Warray-bounds flags even in-prefix accesses through it. The definition/declaration mismatch is a recorded rule 8.3/8.4 deviation (ADR-050), which an LTO build would report. avr-uno-t1 (avr-gcc 14.4.0): kernel static RAM 64 -> 43 bytes, idle context 32 -> 11, kernel text 4126 -> 4082; avr-uno 69 -> 46 and avr-uno-release 67 -> 44 bytes. The new case idle_context_is_not_an_application_thread checks handle rejection, the null self handle and the 0xFE trace index. KRN-THR-026, SPEC-008 §11, SPEC-011, the AVR port page, STATUS, the checklist and the changelog are updated. Signed-off-by: teo1747 --- CHANGELOG.md | 2 ++ docs/STATUS.md | 2 +- docs/architecture/06-decision-records.md | 13 ++++++++ docs/engineering/m2-checklist.md | 2 +- docs/ports/avr.md | 2 +- docs/requirements/KRN-THR.md | 8 ++--- docs/specs/SPEC-008-thread-lifecycle.md | 2 +- .../SPEC-011-architecture-port-contract.md | 2 +- include/emb/thread.h | 3 ++ kernel/CMakeLists.txt | 3 ++ kernel/exec/exec.c | 4 +-- kernel/include/embk/kernel.h | 1 + kernel/include/embk/sched.h | 10 +++++- kernel/sched/idle_context.c | 32 +++++++++++++++++++ kernel/sched/sched.c | 21 ++++++++---- kernel/thread/thread.c | 13 ++++++-- tests/conformance/CMakeLists.txt | 1 + tests/conformance/test_thread_identity.c | 28 ++++++++++++++++ 18 files changed, 127 insertions(+), 22 deletions(-) create mode 100644 kernel/sched/idle_context.c diff --git a/CHANGELOG.md b/CHANGELOG.md index df3ec33..3824f87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Changed +- The idle context is no longer reachable through a thread handle, and `emb_thread_self()` returns `EMB_HANDLE_NULL` in it (KRN-THR-026, ADR-050). On the tiny profile without an idle thread only the prefix of its control block is allocated: AVR `footprint_t1` kernel static RAM drops from 64 to 43 bytes. + - The historical M2-01 `cancel_waiter_disinherits` seed-5 report is closed as not reproducible (input unavailable, cause unknown) after bounded triage: not fixed, not classified, still listed among the known limits, with named reopen triggers. `timing-and-evidence.md` defines that disposition. - `emb_sem_init()` and `emb_mutex_init()` refuse storage that still holds an active object of their type (fault in checked builds, `EMB_EBUSY` in release) instead of overwriting it, when `CONFIG_EMB_OBJ_LIST` is on (default except on the tiny profile). Storage is reused only through destroy. Semaphore and mutex storage grows by one list node where the option is on. KRN-OBJ-007 now specifies `EMB_EBUSY` instead of `EMB_EEXIST` (ADR-045). diff --git a/docs/STATUS.md b/docs/STATUS.md index 8e7ca86..d6b2a5d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -8,7 +8,7 @@ This page is the authoritative inventory of implementation and available evidenc | Area | Current implementation | Evidence and limits | |---|---|---| -| Execution and scheduling | UP fixed priority, base bitmap/FIFO scheduler, tiny thread table, scheduler lock, thread lifecycle | Common conformance cases; tiny has unique base priorities and restricted scheduling semantics | +| Execution and scheduling | UP fixed priority, base bitmap/FIFO scheduler, tiny thread table, scheduler lock, thread lifecycle; idle context unreachable through handles, and slim (control-block prefix only) on tiny without an idle thread (ADR-050) | Common conformance cases; tiny has unique base priorities and restricted scheduling semantics | | Waiting and time | Shared wait protocol, sleep, deadlines, cancellation, suspend overlay, ordered timeouts | Native virtual time exercises periodic and tickless modes; embedded ports currently reject tickless | | Synchronization | Semaphores, mutexes with inheritance, ceiling, recursion and owner-death policy | Conformance and native differential bridge; inheritance and deadlock detection have a configured depth limit | | Notifications | Per-thread bits and one semaphore-to-thread binding | Binding takes a one-bit mask; no unbind implementation; pointer lifetime remains caller responsibility | diff --git a/docs/architecture/06-decision-records.md b/docs/architecture/06-decision-records.md index 1779b98..641f137 100644 --- a/docs/architecture/06-decision-records.md +++ b/docs/architecture/06-decision-records.md @@ -539,3 +539,16 @@ Each record: context, decision, alternatives, consequences, status. **Proposed** **Consequences.** Each semaphore and mutex grows by one list node (two pointers) where the list is configured; the generated storage types follow automatically. Init costs a walk of the active objects of its type with interrupts masked. Tiny keeps its RAM, flash and init cost, and its semaphore/mutex double init stays undefined. New object types (event flags, condition variables, queues, work queues, timers) join the same scheme. POINTER handles still carry no generation: a stale copy of a handle to destroyed and re-initialized storage refers to the new object (SPEC-009 §3.1). **Status.** Implemented for review, 2026-10-27, as part of completing M2-02; awaiting project-owner review. + +## ADR-050 Slim idle context on the table profile + +**Context.** R-003 T2 counts the idle context in the tiny kernel's fixed 64 bytes, and the reference build sat at that limit with a full 32-byte control block for idle. Idle never blocks, times out, takes notifications, joins or changes priority. The scheduler, the switch path and diagnostics read only its leading fields: `arch` (the ports, through the saved stack pointer and the native port's host context), `obj` (type tag and, packed, the diagnostic index), the priority and state bytes, `tflags`, `cancel_disable`, the index byte where not packed, and `lock_depth` in checked builds. A handle to idle could still be formed with `emb_thread_self()` in the idle context, and `emb_notify_set()` did not refuse it. + +**Decision.** Give the idle context its own type tag (`EMBK_OBJ_IDLE`) on every profile, so no handle resolves to it, and make `emb_thread_self()` return `EMB_HANDLE_NULL` in the idle context. With the table scheduler and no idle thread, allocate only the prefix in front of `wait_gen`: `kernel/sched/idle_context.c` defines `embk_idle_thread` as an aligned byte array of that size, while every other translation unit keeps declaring it as a full `embk_thread_t` and touches only the prefix. Idle initialization clears only the prefix, and the checked stack-guard check skips the idle context, which has no stack record. Ports touch only `idle->arch`. + +**Alternatives.** A prefix-sized storage object reached through a cast to `embk_thread_t *` makes the size mismatch visible to the compiler: GCC 14 rejects even in-prefix accesses with `-Warray-bounds`, and the access is undefined behavior it may reason about. A separate idle structure used through distinct code paths would special-case every scheduler, trace and fault site and cost flash on a build within 67 bytes of its text limit. Overlaying other kernel data on the unused tail of a full control block saves the same bytes but makes unrelated objects depend on idle never writing its tail. + +**Consequences.** AVR `footprint_t1` kernel static RAM falls from 64 to 43 bytes and its kernel text by 44 bytes (avr-gcc 14.4.0); Cortex-M, RV32 and native tiny builds shrink by their own prefix sizes. The definition's type differs from the declaration on purpose, a recorded MISRA rule 8.3/8.4 deviation that requires the kernel not to be built with LTO (which would report it). KRN-THR-026 now makes idle unreachable rather than refusing operations with `EMB_EPERM`. Code running in the idle context, such as a port's idle hook, must not call thread-context API, which would reach fields the slim idle context does not have. Traces and fault records still name idle by index 0xFE. A field moved in front of `wait_gen`, or a new idle access, must keep the static assertions in `idle_context.c` true. + +**Status.** Implemented for review, 2026-10-27, as part of the M2 tiny-RAM headroom work; awaiting project-owner review. + diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index 32a6cdb..cfaf973 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -70,7 +70,7 @@ The [timing/evidence contract](timing-and-evidence.md) controls these artifacts. The `footprint_t1` reference build is at the R-003 T2 limit of 64 bytes of kernel static RAM, which includes the idle context's control block (R-003 §4, T2 accounting). This work is independent of the M2 order; until it lands, a change that adds tiny kernel RAM must remove as much in the same change. - [ ] Small savings of about 6 bytes: bind the fault hook at link time instead of through a RAM pointer, pack the per-CPU flags, and drop the idle slot from the priority-indexed thread table. -- [ ] Slim idle context: idle never blocks, times out, takes notifications, joins or changes priority, so on the tiny profile it needs only its saved stack pointer and the fields the scheduler and switch path read. Expected to recover about 25 of its 32 bytes. Check every path that treats idle as a full `embk_thread_t` (switch, diagnostics, identity, trace) on native, AVR, Cortex-M and RV32 before changing its type. +- [x] Slim idle context: idle never blocks, times out, takes notifications, joins or changes priority, so on the tiny profile it needs only its saved stack pointer and the fields the scheduler and switch path read. Expected to recover about 25 of its 32 bytes. Check every path that treats idle as a full `embk_thread_t` (switch, diagnostics, identity, trace) on native, AVR, Cortex-M and RV32 before changing its type. Done under ADR-050: an 11-byte prefix on AVR, `footprint_t1` kernel RAM 64 → 43 bytes; no handle resolves to idle and `emb_thread_self()` is null there. - [ ] Lower the `avr-uno-t1` RAM threshold to the new measured value so the recovered bytes are not silently spent. ## M2-05 through M2-09: Primitive acceptance diff --git a/docs/ports/avr.md b/docs/ports/avr.md index 9e5fd5d..212cee0 100644 --- a/docs/ports/avr.md +++ b/docs/ports/avr.md @@ -60,7 +60,7 @@ Measured with `tools/footprint/footprint.py` from the link map; the kernel total What the pass changed (amendments A15 to A17): linker relaxation and shared prologues in the reference build; no padding byte in the object header; the exit code in `wake_data`; the wake result and notification mode in the thread's `obj.flags` bits; a singly linked timeout list without a generation field on the tiny profile; the clock sequence counter only where the port reads the clock through it; the raised-priority count only with mutexes; one table scan shared by the scheduler and the wait queues. The first M1 baseline, for the record, was 4577 / 84 / 42 (release, `first_execution`). -The `footprint_t1` kernel static RAM is at the T2 limit. Of its 64 bytes, the idle context's control block takes 32, the priority-indexed thread table 16, the tick count and timeout list head 6, the per-CPU state 6, the fault hook pointer 2, and the ready set and the diagnostic index bitmap 1 each. The idle control block stays in the kernel's fixed cost (R-003 §4, T2 accounting); the planned headroom work is in the [M2 work order](../engineering/m2-checklist.md#tiny-profile-ram-headroom). +The `footprint_t1` kernel static RAM was at the T2 limit of 64 bytes, 32 of them the idle context's full control block. The slim idle context (ADR-050) keeps only the 11-byte prefix the scheduler and the switch path read: the reference build measures 43 bytes. These are the idle context 11, the priority-indexed thread table 16, the tick count and timeout list head 6, the per-CPU state 6, the fault hook pointer 2, and the ready set and the diagnostic index bitmap 1 each (avr-gcc 14.4.0, source of the slim-idle change; the CI gate's gcc-avr 7.3 lays out the same data). The idle context stays in the kernel's fixed cost (R-003 §4, T2 accounting); the remaining headroom work is in the [M2 work order](../engineering/m2-checklist.md#tiny-profile-ram-headroom). ## Skipped conformance tests diff --git a/docs/requirements/KRN-THR.md b/docs/requirements/KRN-THR.md index 6a5a84e..7f0b867 100644 --- a/docs/requirements/KRN-THR.md +++ b/docs/requirements/KRN-THR.md @@ -184,10 +184,10 @@ KRN-THR-001 to 008 originate in the v0.1 specification (§3.3); KRN-THR-009 to 0 **Trace.** SPEC-008 §3, §13 ### KRN-THR-026 Kernel threads -**Statement.** The idle thread and work queue threads shall be kernel threads: not suspendable, cancelable, joinable, or re-prioritizable by the application (`EMB_EPERM`), invisible to `foreach` unless asked for. -**Rationale.** The kernel's own progress must not depend on application calls. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `misuse/kernel_thread_ops`. +**Statement.** The idle context shall not be reachable through any thread handle, and `emb_thread_self()` shall return `EMB_HANDLE_NULL` in it. Work queue threads shall be kernel threads: not suspendable, cancelable, joinable, or re-prioritizable by the application (`EMB_EPERM`), invisible to `foreach` unless asked for. +**Rationale.** The kernel's own progress must not depend on application calls. An idle context that no handle names cannot be operated on at all, and need not be a full control block (ADR-050). +**Status.** Accepted 2026-10-07; amended 2026-10-27 (ADR-050): the idle context is unreachable instead of refusing operations with `EMB_EPERM`. +**Verification.** Test: `misuse/kernel_thread_ops`, `idle_context_is_not_an_application_thread`. **Trace.** SPEC-008 §11 ### KRN-THR-027 Isolated profile rules diff --git a/docs/specs/SPEC-008-thread-lifecycle.md b/docs/specs/SPEC-008-thread-lifecycle.md index 24dbd68..d9c37d9 100644 --- a/docs/specs/SPEC-008-thread-lifecycle.md +++ b/docs/specs/SPEC-008-thread-lifecycle.md @@ -121,7 +121,7 @@ The exiting thread's stack is in use until step 5. Interrupts taken between step ## 11. Idle and kernel threads -- The idle thread exists per CPU unless the tiny profile removes it (KRN-SCH-043, ADR-036); its priority is `0`, it is not visible as an application thread, cannot be suspended, canceled, joined, or re-prioritized (`EMB_EPERM`), and it runs the power core's idle hook (04 §4). +- The idle thread exists per CPU unless the tiny profile removes it (KRN-SCH-043, ADR-036); its priority is `0` and it runs the power core's idle hook (04 §4). It is not an application thread: its object carries the idle type tag, so no handle resolves to it, and `emb_thread_self()` returns `EMB_HANDLE_NULL` in the idle context (KRN-THR-026). With the table scheduler and no idle thread, the inline idle context is slim: it never blocks, times out, takes notifications, joins or changes priority, so only the control block's prefix that the scheduler, the switch path and diagnostics read is allocated (ADR-050). Code running in the idle context, such as a port's idle hook, must not call thread-context API. - Work queue threads (SPEC-006) are kernel threads: created by `emb_workq_init`, destroyed with the queue, same restrictions as idle except priority, which is the queue's. - The supervisor partition's thread (03 §8) is an ordinary thread of a privileged partition. diff --git a/docs/specs/SPEC-011-architecture-port-contract.md b/docs/specs/SPEC-011-architecture-port-contract.md index 2f7003f..465a178 100644 --- a/docs/specs/SPEC-011-architecture-port-contract.md +++ b/docs/specs/SPEC-011-architecture-port-contract.md @@ -69,7 +69,7 @@ The thread's register context lives **on its own stack**; the TCB holds only the |---|---|---| | `void emb_arch_early_init(void)` | SoC startup, before `main()` | vector table base, FPU enable and lazy stacking, stack limit registers for the pre-kernel stack, cache enable where the SoC asks, trap vector (`mtvec`) | | `void emb_arch_init(void)` | `emb_kernel_init()` | interrupt stack installation, priority grouping, PendSV and SVC priorities (SPEC-002 §13), timer source selection, cycle counter start | -| `EMB_NORETURN void emb_arch_kernel_start(embk_thread_t *first, embk_thread_t *idle)` | `emb_kernel_start()` at P3 | switches from the pre-kernel stack to the first thread's context and never returns; afterwards the pre-kernel stack is the interrupt stack (ports with `interrupt_stack`) or the idle thread's stack (AVR), per SPEC-008 §10 | +| `EMB_NORETURN void emb_arch_kernel_start(embk_thread_t *first, embk_thread_t *idle)` | `emb_kernel_start()` at P3 | switches from the pre-kernel stack to the first thread's context and never returns; afterwards the pre-kernel stack is the interrupt stack (ports with `interrupt_stack`) or the idle thread's stack (AVR), per SPEC-008 §10. The port touches only `idle->arch`: on the tiny profile the idle context ends shortly after it (ADR-050) | Startup forms per target follow 09 §8: C `Reset_Handler` on Cortex-M, a `.S` entry on RISC-V and AVR; the SoC layer owns clocks and memory setup and calls `emb_arch_early_init` before touching anything interrupt-related. diff --git a/include/emb/thread.h b/include/emb/thread.h index 42f4c17..4a63e1e 100644 --- a/include/emb/thread.h +++ b/include/emb/thread.h @@ -139,6 +139,9 @@ emb_status_t emb_thread_start(emb_thread_t thread); * @req KRN-THR-008 * @since 0.2 * @stable yes + * + * Return: the caller's handle; EMB_HANDLE_NULL outside thread context and in the idle + * context, which is not an application thread (SPEC-008 §11). */ emb_thread_t emb_thread_self(void); diff --git a/kernel/CMakeLists.txt b/kernel/CMakeLists.txt index 809ef4f..c43dbfa 100644 --- a/kernel/CMakeLists.txt +++ b/kernel/CMakeLists.txt @@ -12,6 +12,9 @@ set(EMB_KERNEL_SOURCES fault/fault.c trace/trace.c irq/irq.c) +if(CONFIG_EMB_SCHED_TABLE AND NOT CONFIG_EMB_IDLE_THREAD) + list(APPEND EMB_KERNEL_SOURCES sched/idle_context.c) # the slim idle context (ADR-050) +endif() if(CONFIG_EMB_NOTIFY) list(APPEND EMB_KERNEL_SOURCES notify/notify.c) endif() diff --git a/kernel/exec/exec.c b/kernel/exec/exec.c index 5c4e8a5..b573b9b 100644 --- a/kernel/exec/exec.c +++ b/kernel/exec/exec.c @@ -65,7 +65,7 @@ void emb_kernel_start(void) if (first != NULL) { embk_sched_remove_ready(first); } else { - first = &embk_idle_thread; + first = EMBK_IDLE; } embk_cpu.current = first; embk_cpu.reschedule_pending = 0u; @@ -76,7 +76,7 @@ void emb_kernel_start(void) #endif EMBK_TRACE(EMB_TRACE_SWITCH, 0xFFu, embk_thread_index(first), 0u); (void)key; /* the launch restores the first thread's own interrupt state (P3) */ - emb_arch_kernel_start(first, &embk_idle_thread); + emb_arch_kernel_start(first, EMBK_IDLE); } void embk_idle_loop(void) diff --git a/kernel/include/embk/kernel.h b/kernel/include/embk/kernel.h index 2f0ed6c..36113b4 100644 --- a/kernel/include/embk/kernel.h +++ b/kernel/include/embk/kernel.h @@ -35,6 +35,7 @@ /* ---- object header (SPEC-009) ------------------------------------------------- */ +#define EMBK_OBJ_IDLE ((uint8_t)0x50u) /* the idle context: no handle resolves to it */ #define EMBK_OBJ_THREAD ((uint8_t)0x51u) #define EMBK_OBJ_SEM ((uint8_t)0x52u) #define EMBK_OBJ_MUTEX ((uint8_t)0x53u) diff --git a/kernel/include/embk/sched.h b/kernel/include/embk/sched.h index 14799c3..5af3c24 100644 --- a/kernel/include/embk/sched.h +++ b/kernel/include/embk/sched.h @@ -13,7 +13,15 @@ #include -extern embk_thread_t embk_idle_thread; /* the idle context; prio 0; never READY in the structure */ +/* The idle context: priority 0, never READY in the structure, not an application thread + * (SPEC-008 §11). With the table scheduler and no idle thread it is slim: it never + * blocks, times out, takes notifications, joins or changes priority, so only the control + * block's prefix that the scheduler, the switch path and diagnostics read (arch through + * lock_depth) is allocated, by sched/idle_context.c; nothing reads past it (ADR-050). */ +#define EMBK_IDLE_SLIM (CONFIG_EMB_SCHED_TABLE && !CONFIG_EMB_IDLE_THREAD) +#define EMBK_IDLE_BYTES offsetof(embk_thread_t, wait_gen) +extern embk_thread_t embk_idle_thread; +#define EMBK_IDLE (&embk_idle_thread) #if CONFIG_EMB_SCHED_TABLE extern embk_thread_t *embk_thread_table[CONFIG_EMB_PRIORITY_COUNT]; /* slot = priority (ADR-036) */ diff --git a/kernel/sched/idle_context.c b/kernel/sched/idle_context.c new file mode 100644 index 0000000..267efdb --- /dev/null +++ b/kernel/sched/idle_context.c @@ -0,0 +1,32 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* + * The slim idle context (ADR-050): the table profile without an idle thread allocates + * only the prefix of a control block that the scheduler, the switch path and diagnostics + * read for idle. Every other translation unit declares embk_idle_thread as a full + * embk_thread_t (embk/sched.h) and touches only these leading fields of it, so this file + * must not include that header. + * + * MISRA Dev CS-12: rules 8.3/8.4, the definition's type differs from the declaration on + * purpose. The kernel is not built with LTO, since the layout probe needs real object + * code; an LTO build would report this mismatch (-Wlto-type-mismatch) and fail. + */ +#include + +#include + +#define IDLE_BYTES offsetof(embk_thread_t, wait_gen) + +/* Every field the kernel or a port reads or writes for the idle context lies in front. */ +EMB_STATIC_ASSERT(offsetof(embk_thread_t, arch) == 0u, "idle prefix: arch"); +EMB_STATIC_ASSERT(offsetof(embk_thread_t, obj) < IDLE_BYTES, "idle prefix: obj"); +EMB_STATIC_ASSERT(offsetof(embk_thread_t, tflags) < IDLE_BYTES, "idle prefix: tflags"); +EMB_STATIC_ASSERT(offsetof(embk_thread_t, cancel_disable) < IDLE_BYTES, "idle prefix"); +#if !EMBK_THREAD_INDEX_PACKED +EMB_STATIC_ASSERT(offsetof(embk_thread_t, index) < IDLE_BYTES, "idle prefix: index"); +#endif +#if CONFIG_EMB_CHECKED +EMB_STATIC_ASSERT(offsetof(embk_thread_t, lock_depth) < IDLE_BYTES, "idle prefix: lock_depth"); +#endif + +alignas(embk_thread_t) unsigned char embk_idle_thread[IDLE_BYTES]; diff --git a/kernel/sched/sched.c b/kernel/sched/sched.c index 3674228..2bf5a0c 100644 --- a/kernel/sched/sched.c +++ b/kernel/sched/sched.c @@ -18,7 +18,9 @@ #include #include -embk_thread_t embk_idle_thread; +#if !EMBK_IDLE_SLIM +embk_thread_t embk_idle_thread; /* slim: sched/idle_context.c allocates the prefix only */ +#endif #if CONFIG_EMB_SCHED_TABLE embk_thread_t *embk_thread_table[CONFIG_EMB_PRIORITY_COUNT]; @@ -65,7 +67,7 @@ void embk_sched_init(void) ready_summary = 0u; #endif #endif - embk_thread_init_idle(&embk_idle_thread, NULL, 0u); + embk_thread_init_idle(EMBK_IDLE, NULL, 0u); } /* ---- the structure ------------------------------------------------------------ */ @@ -173,7 +175,7 @@ bool embk_sched_should_preempt(uint8_t prio) if (cur == NULL) { return false; /* prekernel: the start launches the highest */ } - if (cur == &embk_idle_thread || !thread_runnable(cur)) { + if (cur == EMBK_IDLE || !thread_runnable(cur)) { return true; } return prio > cur->eff_prio; @@ -182,7 +184,7 @@ bool embk_sched_should_preempt(uint8_t prio) void embk_sched_make_ready(embk_thread_t *t, bool ahead) { EMBK_ASSERT(!embk_sched_is_ready(t)); - EMBK_ASSERT(t != &embk_idle_thread); + EMBK_ASSERT(t != EMBK_IDLE); insert(t, ahead); if (embk_sched_should_preempt(t->eff_prio)) { embk_cpu.reschedule_pending = 1u; @@ -196,7 +198,7 @@ void embk_sched_make_ready(embk_thread_t *t, bool ahead) bool embk_sched_current_runnable(void) { const embk_thread_t *cur = embk_cpu.current; - return cur != &embk_idle_thread && thread_runnable(cur); + return cur != EMBK_IDLE && thread_runnable(cur); } /* ---- the decision --------------------------------------------------------------- */ @@ -205,6 +207,11 @@ static void check_stack(const embk_thread_t *t) { #if CONFIG_EMB_STACK_CHECK unsigned i; +#if EMBK_IDLE_SLIM + if (t == EMBK_IDLE) { + return; /* it runs on the pre-kernel stack and keeps no stack record (ADR-050) */ + } +#endif if (t->stack_base != NULL) { /* bounded: EMBK_STACK_GUARD_BYTES */ for (i = 0u; i < EMBK_STACK_GUARD_BYTES; i++) { @@ -225,7 +232,7 @@ embk_thread_t *embk_sched_select(bool yield) { embk_thread_t *cur = embk_cpu.current; embk_thread_t *next = embk_sched_peek(); - bool runnable = cur != &embk_idle_thread && thread_runnable(cur); + bool runnable = cur != EMBK_IDLE && thread_runnable(cur); embk_cpu.reschedule_pending = 0u; if (runnable) { @@ -238,7 +245,7 @@ embk_thread_t *embk_sched_select(bool yield) if (next != NULL) { embk_sched_remove_ready(next); } else { - next = &embk_idle_thread; + next = EMBK_IDLE; } if (next == cur) { return NULL; diff --git a/kernel/thread/thread.c b/kernel/thread/thread.c index f253c1b..fa84d4f 100644 --- a/kernel/thread/thread.c +++ b/kernel/thread/thread.c @@ -104,8 +104,12 @@ static void idle_entry(void *arg) void embk_thread_init_idle(embk_thread_t *t, void *stack, size_t stack_size) { +#if EMBK_IDLE_SLIM + (void)EMB_MEMSET(t, 0, EMBK_IDLE_BYTES); /* the prefix is all there is (ADR-050) */ +#else tcb_reset(t); - embk_obj_init(&t->obj, EMBK_OBJ_THREAD, 0u, "idle"); +#endif + embk_obj_init(&t->obj, EMBK_OBJ_IDLE, 0u, "idle"); /* no handle resolves to it */ t->base_prio = EMB_PRIORITY_IDLE; t->eff_prio = EMB_PRIORITY_IDLE; t->wait_state = EMBK_WAIT_READY; @@ -122,6 +126,9 @@ void embk_thread_init_idle(embk_thread_t *t, void *stack, size_t stack_size) t->stack_size = sizeof(idle_stack); (void)EMB_MEMSET(idle_stack, EMB_STACK_FILL, sizeof(idle_stack)); emb_arch_context_init(t, idle_stack, sizeof(idle_stack), idle_entry, NULL, true); +#elif EMBK_IDLE_SLIM + (void)stack; /* the port supplies the pre-kernel stack at start (SPEC-012 §3) */ + (void)stack_size; #else t->stack_base = (uint8_t *)stack; /* the port supplies the pre-kernel stack at start (SPEC-012 §3) */ @@ -264,7 +271,7 @@ emb_thread_t emb_thread_self(void) { emb_thread_t h; h.raw = 0u; - if (embk_in_thread()) { + if (embk_in_thread() && embk_cpu.current != EMBK_IDLE) { /* idle is not a thread (§11) */ h = embk_thread_handle(embk_cpu.current); } return h; @@ -278,7 +285,7 @@ void emb_thread_exit(int code) embk_thread_t *joiner; emb_irq_key_t key; - if (!embk_in_thread() || embk_cpu.current == &embk_idle_thread) { + if (!embk_in_thread() || embk_cpu.current == EMBK_IDLE) { embk_fault_raise_api(EMB_API_ID_THREAD_EXIT, EMB_FAULT_API_CONTEXT, 0u, 0u, EMBK_WHERE); } self = embk_cpu.current; diff --git a/tests/conformance/CMakeLists.txt b/tests/conformance/CMakeLists.txt index 230de14..566bb8b 100644 --- a/tests/conformance/CMakeLists.txt +++ b/tests/conformance/CMakeLists.txt @@ -9,6 +9,7 @@ emb_add_test(test_irq test_irq.c) emb_add_test(test_irq_priority test_irq_priority.c) emb_add_test(test_thread test_thread.c) emb_add_test(test_thread_identity test_thread_identity.c) +target_link_libraries(test_thread_identity PRIVATE emb_kernel_private) emb_add_test(test_time test_time.c) if(CONFIG_EMB_NOTIFY) emb_add_test(test_notify test_notify.c) diff --git a/tests/conformance/test_thread_identity.c b/tests/conformance/test_thread_identity.c index 66a14d8..1b4caa7 100644 --- a/tests/conformance/test_thread_identity.c +++ b/tests/conformance/test_thread_identity.c @@ -1,6 +1,9 @@ /* SPDX-License-Identifier: Apache-2.0 */ /* Copyright 2026 Junior Deogracias */ /* Diagnostic identity is independent of scheduler priority and execution lifetime. */ +#include +#include + #include static void identity_entry(void *arg) @@ -172,3 +175,28 @@ EMB_TEST(thread_storage_reuse_requires_destroy) EMB_ASSERT_OK(emb_thread_init(emb_test_scratch_storage(), &attr, identity_entry, NULL, &again)); EMB_ASSERT_OK(emb_thread_destroy(again)); } + +EMB_TEST(idle_context_is_not_an_application_thread) +{ + emb_thread_t idle; + emb_thread_t self; + embk_thread_t *saved; + emb_irq_key_t key; + uint8_t state = 0u; + EMB_TEST_REQ("KRN-THR-025", "KRN-THR-026"); + idle.raw = embk_obj_to_raw(EMBK_IDLE); + EMB_ASSERT_EQ(embk_thread_index(EMBK_IDLE), 0xFEu); /* traces and faults still name it */ + EMB_ASSERT_EQ(emb_thread_index(idle), 0xFFu); /* no handle resolves to it */ + EMB_ASSERT_EQ(emb_thread_get_priority(idle), 0u); + EMB_ASSERT_MISUSE(emb_thread_state(idle, &state, NULL), EMB_EINVAL); +#if CONFIG_EMB_NOTIFY + EMB_ASSERT_MISUSE(emb_notify_set(idle, EMB_NOTIFY_BIT(0)), EMB_EINVAL); +#endif + key = emb_irq_lock(); + saved = embk_cpu.current; + embk_cpu.current = EMBK_IDLE; /* as seen from the idle context */ + self = emb_thread_self(); + embk_cpu.current = saved; + emb_irq_unlock(key); + EMB_ASSERT_EQ(self.raw, 0u); +} From f9a864f6f8769bcb142d0fe4832c03df81d477d6 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 18:13:15 +0100 Subject: [PATCH 07/30] style(list): format the static initializer as clang-format 18.1.3 does The lint job's clang-format 18.1.3 lays out a braced initializer in a macro over several lines; 18.1.8, used locally, kept it on one. No code change. Signed-off-by: teo1747 --- kernel/include/embk/list.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/kernel/include/embk/list.h b/kernel/include/embk/list.h index 07f47ec..7440e2a 100644 --- a/kernel/include/embk/list.h +++ b/kernel/include/embk/list.h @@ -124,6 +124,11 @@ static EMB_INLINE bool embk_list_contains(const embk_list_t *l, const embk_list_ } /* A static initializer for an empty list, for lists used before any init code runs. */ -#define EMBK_LIST_INITIALIZER(l) {{&(l).head, &(l).head}} +#define EMBK_LIST_INITIALIZER(l) \ + { \ + { \ + &(l).head, &(l).head \ + } \ + } #endif /* EMBK_LIST_H */ From 7abc439888b8b1cbbcbe088642408f609e4bf1f5 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 18:19:37 +0100 Subject: [PATCH 08/30] perf(tiny): drop the idle table slot, bind the fault hook at link time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the small RAM savings in the M2 tiny-headroom work, and the lowered gate. The table scheduler's thread array had a slot for idle that nothing read after init: idle's level 0 is never in the ready set or a wait bitmap. EMBK_TABLE_SLOT(p) now addresses slot p - 1, an offset the compiler folds into the symbol address. A checked-build assertion guards that bit 0 never reaches a table scan. 2 bytes on AVR. CONFIG_EMB_FAULT_HOOK_RUNTIME (default y) keeps emb_fault_set_hook(). With n the kernel calls emb_fault_hook(), which the application defines, and keeps no pointer in RAM. A weak empty default exists where the compiler has weak symbols; EMB_HAS_WEAK reports which. The native tests that set a hook at run time keep the default, so only configs/footprint-t1.conf selects n. emb_fault_hook gets API ID 83. 2 bytes on AVR. Packing the per-CPU flags into one byte was evaluated and not done. The masked reads would be inlined into every public entry and cost more flash than the one or two bytes saved, and flash is the tighter T1 budget. avr-uno-t1 kernel static RAM is now 39 bytes (43 after the slim idle context, 64 before) and its gate is lowered to that value. The first_execution gates follow their baseline-plus-10-percent rule: 48 (checked, 44 measured) and 46 (release, 42). R-003, ADR-036's note, SPEC-008 §14, KRN-THR-029, the AVR port page, the checklist and the changelog are updated. Signed-off-by: teo1747 --- CHANGELOG.md | 2 ++ configs/footprint-t1.conf | 1 + docs/architecture/06-decision-records.md | 2 +- docs/engineering/m2-checklist.md | 6 ++--- docs/ports/avr.md | 12 ++++++---- docs/requirements/KRN-THR.md | 2 +- docs/research/R-003-differentiation.md | 2 +- docs/specs/SPEC-008-thread-lifecycle.md | 2 +- include/emb/api_ids.h | 1 + include/emb/compiler.h | 3 +++ include/emb/fault.h | 24 ++++++++++++++++++- kernel/Kconfig | 8 +++++++ kernel/fault/fault.c | 12 ++++++++++ kernel/include/embk/sched.h | 4 +++- kernel/sched/sched.c | 15 ++++++------ kernel/thread/thread.c | 15 +++++------- tools/fault/api-ids.json | 6 +++++ tools/fault/api-registry.json | 6 +++++ .../footprint/thresholds-avr-uno-release.json | 4 ++-- tools/footprint/thresholds-avr-uno-t1.json | 4 ++-- tools/footprint/thresholds-avr-uno.json | 4 ++-- 21 files changed, 99 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3824f87..4e07f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Changed +- The tiny thread table has no slot for idle, and `CONFIG_EMB_FAULT_HOOK_RUNTIME=n` binds the fault hook at link time through an application-defined `emb_fault_hook()` (weak empty default), keeping no hook pointer in RAM. The `footprint_t1` reference configuration uses it; AVR `footprint_t1` kernel static RAM is 39 bytes and its gate is lowered from 64 to that value. + - The idle context is no longer reachable through a thread handle, and `emb_thread_self()` returns `EMB_HANDLE_NULL` in it (KRN-THR-026, ADR-050). On the tiny profile without an idle thread only the prefix of its control block is allocated: AVR `footprint_t1` kernel static RAM drops from 64 to 43 bytes. - The historical M2-01 `cancel_waiter_disinherits` seed-5 report is closed as not reproducible (input unavailable, cause unknown) after bounded triage: not fixed, not classified, still listed among the known limits, with named reopen triggers. `timing-and-evidence.md` defines that disposition. diff --git a/configs/footprint-t1.conf b/configs/footprint-t1.conf index 08e3694..b88e122 100644 --- a/configs/footprint-t1.conf +++ b/configs/footprint-t1.conf @@ -5,3 +5,4 @@ # CONFIG_EMB_CHECKED is not set # CONFIG_EMB_MUTEX is not set # CONFIG_EMB_TRACE is not set +# CONFIG_EMB_FAULT_HOOK_RUNTIME is not set diff --git a/docs/architecture/06-decision-records.md b/docs/architecture/06-decision-records.md index 641f137..e449e21 100644 --- a/docs/architecture/06-decision-records.md +++ b/docs/architecture/06-decision-records.md @@ -428,7 +428,7 @@ Each record: context, decision, alternatives, consequences, status. **Proposed** **Context.** ChibiOS NIL shows that a kernel for a handful of threads needs no ready list at all: the thread array is indexed by priority and the scheduler scans for the first ready entry (R-001 §1.1). uC/OS-III 3.08, ChibiOS, and RIOT make the idle thread optional and idle inside the scheduler. Both save RAM that matters on a 2 KB AVR. ChibiOS pays for this with two kernels; the class interface (KRN-SCH-038) lets EmbLinkRTOS do it with one. -**Decision.** The tiny profile may select `CONFIG_EMB_SCHED_TABLE`: threads are a fixed array indexed by priority, priorities are unique, the ready set is one bitmap word, selection is count-leading-zeros or a scan, and a wait queue is a bitmap of threads (so priority order is free and wake-highest is O(1)). Up to 16 threads including the idle slot. `CONFIG_EMB_IDLE_THREAD=n` removes the idle thread; the scheduler idles inline with interrupts enabled at the SPEC-002 preemption points. The public API and the conformance suite are unchanged within the tiny profile's documented restrictions (unique priorities, no round robin). +**Decision.** The tiny profile may select `CONFIG_EMB_SCHED_TABLE`: threads are a fixed array indexed by priority, priorities are unique, the ready set is one bitmap word, selection is count-leading-zeros or a scan, and a wait queue is a bitmap of threads (so priority order is free and wake-highest is O(1)). Up to 16 threads including the idle slot. (2026-10-27: the array no longer has a slot for idle, whose level 0 is never in a ready or wait set; priority p uses slot p − 1.) `CONFIG_EMB_IDLE_THREAD=n` removes the idle thread; the scheduler idles inline with interrupts enabled at the SPEC-002 preemption points. The public API and the conformance suite are unchanged within the tiny profile's documented restrictions (unique priorities, no round robin). **Alternatives.** One bitmap scheduler for every profile (simplest, costs a list per priority on the tiny targets); a second kernel (ChibiOS). diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index cfaf973..dc963cc 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -67,11 +67,11 @@ The [timing/evidence contract](timing-and-evidence.md) controls these artifacts. ## Tiny-profile RAM headroom -The `footprint_t1` reference build is at the R-003 T2 limit of 64 bytes of kernel static RAM, which includes the idle context's control block (R-003 §4, T2 accounting). This work is independent of the M2 order; until it lands, a change that adds tiny kernel RAM must remove as much in the same change. +The `footprint_t1` reference build was at the R-003 T2 limit of 64 bytes of kernel static RAM, which includes the idle context's control block (R-003 §4, T2 accounting). This work is independent of the M2 order. It brought the build to 39 bytes, and the gate now sits at that measured value. -- [ ] Small savings of about 6 bytes: bind the fault hook at link time instead of through a RAM pointer, pack the per-CPU flags, and drop the idle slot from the priority-indexed thread table. +- [x] Small savings of about 6 bytes: bind the fault hook at link time instead of through a RAM pointer, pack the per-CPU flags, and drop the idle slot from the priority-indexed thread table. Done: the thread table has no idle slot (2 bytes), and `CONFIG_EMB_FAULT_HOOK_RUNTIME=n` in the reference configuration calls the application's `emb_fault_hook()` (2 bytes). The per-CPU flags stay unpacked: the masked reads would be inlined into every public entry and cost more flash than the RAM saved (see the [AVR port page](../ports/avr.md)). - [x] Slim idle context: idle never blocks, times out, takes notifications, joins or changes priority, so on the tiny profile it needs only its saved stack pointer and the fields the scheduler and switch path read. Expected to recover about 25 of its 32 bytes. Check every path that treats idle as a full `embk_thread_t` (switch, diagnostics, identity, trace) on native, AVR, Cortex-M and RV32 before changing its type. Done under ADR-050: an 11-byte prefix on AVR, `footprint_t1` kernel RAM 64 → 43 bytes; no handle resolves to idle and `emb_thread_self()` is null there. -- [ ] Lower the `avr-uno-t1` RAM threshold to the new measured value so the recovered bytes are not silently spent. +- [x] Lower the `avr-uno-t1` RAM threshold to the new measured value so the recovered bytes are not silently spent. `kernel_ram_max` is 39 (was 64); the `first_execution` gates follow their baseline-plus-10-percent rule at 48 (checked) and 46 (release) bytes. ## M2-05 through M2-09: Primitive acceptance diff --git a/docs/ports/avr.md b/docs/ports/avr.md index 212cee0..64a08da 100644 --- a/docs/ports/avr.md +++ b/docs/ports/avr.md @@ -50,17 +50,19 @@ Periodic tick on Timer1 CTC: prescaler `CONFIG_EMB_AVR_TIMER1_PRESCALER` (64), ` ## Footprint (avr-gcc 7.3 `-Os -mrelax -mcall-prologues`, tiny profile) -Measured with `tools/footprint/footprint.py` from the link map; the kernel total counts the `kernel/` objects only. The TCB figure is `EMB_THREAD_STORAGE_SIZE` from the build's generated `` (the layout probe, SPEC-012 §13); the idle context is reported beside it and counts in kernel static RAM. The R-003 reference is `samples/footprint_t1` under `configs/footprint-t1.conf` (the T1 feature set: scheduler, notifications, sleep and timeouts, one semaphore; no mutex, no trace), gated per commit on the `avr-uno-t1` preset with the targets as hard limits. The two `first_execution` rows link the mutex as well and are gated at their baseline plus 10 percent. +Measured with `tools/footprint/footprint.py` from the link map; the kernel total counts the `kernel/` objects only. The TCB figure is `EMB_THREAD_STORAGE_SIZE` from the build's generated `` (the layout probe, SPEC-012 §13); the idle context is reported beside it and counts in kernel static RAM. The R-003 reference is `samples/footprint_t1` under `configs/footprint-t1.conf` (the T1 feature set: scheduler, notifications, sleep and timeouts, one semaphore; no mutex, no trace), gated per commit on the `avr-uno-t1` preset with the text and TCB targets as hard limits and kernel static RAM at its measured value. The two `first_execution` rows link the mutex as well and are gated at their baseline plus 10 percent. | preset | program | kernel text | kernel static RAM | thread control block | R-003 target | |---|---|---:|---:|---:|---| -| avr-uno-t1 (release) | `footprint_t1` | 4029 | 64 | 32 | 4096 / 64 / 32 (T1, T2): met, no RAM margin | -| avr-uno-release | `first_execution` | 4131 | 67 | 34 | — | -| avr-uno (checked) | `first_execution` | 5013 | 69 | 35 | — | +| avr-uno-t1 (release) | `footprint_t1` | 4029 | 39 | 32 | 4096 / 64 / 32 (T1, T2): met, 25 bytes of RAM margin | +| avr-uno-release | `first_execution` | 4131 | 42 | 34 | — | +| avr-uno (checked) | `first_execution` | 5013 | 44 | 35 | — | + +The kernel text column is the gcc-avr 7.3 measurement before the M2 RAM headroom work; the RAM column is after it (avr-gcc 14.4.0, which lays out the same data: its kernel text is larger, 4064 bytes for `footprint_t1`, and only the CI compiler's text is gated). What the pass changed (amendments A15 to A17): linker relaxation and shared prologues in the reference build; no padding byte in the object header; the exit code in `wake_data`; the wake result and notification mode in the thread's `obj.flags` bits; a singly linked timeout list without a generation field on the tiny profile; the clock sequence counter only where the port reads the clock through it; the raised-priority count only with mutexes; one table scan shared by the scheduler and the wait queues. The first M1 baseline, for the record, was 4577 / 84 / 42 (release, `first_execution`). -The `footprint_t1` kernel static RAM was at the T2 limit of 64 bytes, 32 of them the idle context's full control block. The slim idle context (ADR-050) keeps only the 11-byte prefix the scheduler and the switch path read: the reference build measures 43 bytes. These are the idle context 11, the priority-indexed thread table 16, the tick count and timeout list head 6, the per-CPU state 6, the fault hook pointer 2, and the ready set and the diagnostic index bitmap 1 each (avr-gcc 14.4.0, source of the slim-idle change; the CI gate's gcc-avr 7.3 lays out the same data). The idle context stays in the kernel's fixed cost (R-003 §4, T2 accounting); the remaining headroom work is in the [M2 work order](../engineering/m2-checklist.md#tiny-profile-ram-headroom). +The `footprint_t1` kernel static RAM was at the T2 limit of 64 bytes, 32 of them the idle context's full control block. The M2 headroom work brought it to 39 bytes. The slim idle context (ADR-050) keeps only the 11-byte prefix the scheduler and the switch path read. The priority-indexed thread table no longer has a slot for idle, whose priority 0 is never in a ready or wait set. The reference configuration binds the fault hook at link time (`CONFIG_EMB_FAULT_HOOK_RUNTIME=n`), so no hook pointer is kept. The 39 bytes are the idle context 11, the thread table 14, the tick count and timeout list head 6, the per-CPU state 6, and the ready set and the diagnostic index bitmap 1 each. The idle context stays in the kernel's fixed cost (R-003 §4, T2 accounting). Packing the per-CPU flags into one byte was evaluated and not done: the reads it adds a mask to are inlined into every public entry, so it would cost more flash than the one or two RAM bytes it saves. ## Skipped conformance tests diff --git a/docs/requirements/KRN-THR.md b/docs/requirements/KRN-THR.md index 7f0b867..6438691 100644 --- a/docs/requirements/KRN-THR.md +++ b/docs/requirements/KRN-THR.md @@ -205,7 +205,7 @@ KRN-THR-001 to 008 originate in the v0.1 specification (§3.3); KRN-THR-009 to 0 **Trace.** SPEC-008 §13 ### KRN-THR-029 Tiny profile -**Statement.** With the table scheduler a thread's slot shall be its unique priority, up to 16 threads including idle; `start`, `suspend`, `resume`, and `join` shall be bit operations; TLS and stack statistics shall default off; destroy shall be allowed only before freeze. +**Statement.** With the table scheduler a thread's slot shall be determined by its unique priority, up to 16 priority levels including idle's level 0, which has no slot; `start`, `suspend`, `resume`, and `join` shall be bit operations; TLS and stack statistics shall default off; destroy shall be allowed only before freeze. **Rationale.** ADR-036; targets T1 and T2. **Status.** Accepted 2026-10-07 **Verification.** Footprint test of the tiny reference configuration; `thr/` conformance group on it. diff --git a/docs/research/R-003-differentiation.md b/docs/research/R-003-differentiation.md index c404ce5..07dea3e 100644 --- a/docs/research/R-003-differentiation.md +++ b/docs/research/R-003-differentiation.md @@ -165,7 +165,7 @@ These are design targets, not measurements. Nothing here is claimed until the ha | T13 | Conformance | every supported target | nobody runs one suite across a 2 KB AVR and an MPU SoC | identical suite passes on tiny and isolated reference configurations | C1, C3 | | T14 | Evidence | every release | Zephyr: 15 kLOC scope certified after the fact | 100% requirement-to-test traceability for the profile under release; statement and branch coverage reported; MISRA-subset compliance reported | C9 | -**T2 accounting.** The fixed 64 bytes are everything the kernel holds in static RAM in the reference build, including the idle context's control block; the per-thread allowance covers the application's threads only. A system's kernel RAM is therefore 64 bytes plus 32 bytes for each thread it creates, with nothing else to add. Re-filing the idle context, or any other kernel object, under the per-thread allowance to make room is not allowed: it frees no memory and only weakens the gate. While the tiny kernel sits at its limit, a change that adds static RAM to it must remove at least as much in the same change. +**T2 accounting.** The fixed 64 bytes are everything the kernel holds in static RAM in the reference build, including the idle context's control block; the per-thread allowance covers the application's threads only. A system's kernel RAM is therefore 64 bytes plus 32 bytes for each thread it creates, with nothing else to add. Re-filing the idle context, or any other kernel object, under the per-thread allowance to make room is not allowed: it frees no memory and only weakens the gate. The reference build's gate sits at its measured static RAM, below this target, so a change that adds static RAM must raise that gate explicitly and justify it in the same change. ## 5. Why someone would still not choose us, and what we do about it diff --git a/docs/specs/SPEC-008-thread-lifecycle.md b/docs/specs/SPEC-008-thread-lifecycle.md index d9c37d9..78afcd8 100644 --- a/docs/specs/SPEC-008-thread-lifecycle.md +++ b/docs/specs/SPEC-008-thread-lifecycle.md @@ -145,7 +145,7 @@ Trace events: `thread_init(t, prio, partition)`, `thread_start`, `thread_exit(t, ## 14. Tiny profile -With the table scheduler (ADR-036), `init` binds the thread to the table slot equal to its priority (unique priorities, up to 16 including idle); `start`, `suspend`, `resume`, `join` are bit operations; no all-threads list is needed (the table is the list); `CONFIG_EMB_TLS_SLOTS=0`, `CONFIG_EMB_STACK_STATS` off by default, `destroy` is allowed only before freeze. Everything else is identical. +With the table scheduler (ADR-036), `init` binds the thread to the table slot of its priority (slot p − 1 for priority p; unique priorities, up to 16 levels including idle's level 0, which has no slot); `start`, `suspend`, `resume`, `join` are bit operations; no all-threads list is needed (the table is the list); `CONFIG_EMB_TLS_SLOTS=0`, `CONFIG_EMB_STACK_STATS` off by default, `destroy` is allowed only before freeze. Everything else is identical. ## 15. Reference model diff --git a/include/emb/api_ids.h b/include/emb/api_ids.h index 70298b1..307548a 100644 --- a/include/emb/api_ids.h +++ b/include/emb/api_ids.h @@ -86,5 +86,6 @@ #define EMB_API_ID_DURATION_IS_SATURATED 0x0050u #define EMB_API_ID_TRACE_SET_SINK 0x0051u #define EMB_API_ID_VERSION_STRING 0x0052u +#define EMB_API_ID_FAULT_HOOK 0x0053u #endif /* EMB_API_IDS_H */ diff --git a/include/emb/compiler.h b/include/emb/compiler.h index ddf38af..1573066 100644 --- a/include/emb/compiler.h +++ b/include/emb/compiler.h @@ -63,6 +63,9 @@ extern "C" { #endif #ifndef EMB_WEAK #define EMB_WEAK +#define EMB_HAS_WEAK 0 /* a default definition cannot be overridden */ +#else +#define EMB_HAS_WEAK 1 #endif #ifndef EMB_ALIAS #define EMB_ALIAS(sym) diff --git a/include/emb/fault.h b/include/emb/fault.h index 447f642..700ab38 100644 --- a/include/emb/fault.h +++ b/include/emb/fault.h @@ -47,6 +47,7 @@ typedef struct emb_fault_info { typedef void (*emb_fault_hook_t)(const emb_fault_info_t *info); +#if CONFIG_EMB_FAULT_HOOK_RUNTIME /** * emb_fault_set_hook() - Register the function called on every kernel fault. * @hook: May run in any context with interrupts masked; NULL removes the hook. @@ -57,12 +58,33 @@ typedef void (*emb_fault_hook_t)(const emb_fault_info_t *info); * @blocks no * @time O(1) * @owns none - * @config + * @config CONFIG_EMB_FAULT_HOOK_RUNTIME * @req API-017 * @since 0.2 * @stable yes */ void emb_fault_set_hook(emb_fault_hook_t hook); +#else +/** + * emb_fault_hook() - The fault hook the application defines when it is bound at link time. + * @info: The fault; the same contract as a hook given to emb_fault_set_hook(): it runs + * in any context with interrupts masked, must be bounded and ISR-safe, and @info + * is a temporary record. + * + * @ctx thread isr prekernel + * @blocks no + * @time the application's + * @owns none + * @config !CONFIG_EMB_FAULT_HOOK_RUNTIME + * @req API-017 + * @since 0.2 + * @stable no + * + * The kernel calls it on every fault, and keeps no hook pointer in RAM. Where the + * compiler supports weak symbols, an empty default applies when none is defined. + */ +void emb_fault_hook(const emb_fault_info_t *info); +#endif /** * emb_fault() - Raise a kernel fault from application or subsystem code. diff --git a/kernel/Kconfig b/kernel/Kconfig index 940bfbd..55e9575 100644 --- a/kernel/Kconfig +++ b/kernel/Kconfig @@ -309,6 +309,14 @@ config EMB_FAULT_HALT_FOR_DEBUG default y if EMB_CHECKED && !EMB_ARCH_AVR default n +config EMB_FAULT_HOOK_RUNTIME + bool "Register the fault hook at run time with emb_fault_set_hook()" + default y + help + API-017. Off: the kernel calls emb_fault_hook(), which the application + defines (a weak empty default exists where the compiler has weak + symbols), and keeps no hook pointer in RAM. + endmenu menu "Observability" diff --git a/kernel/fault/fault.c b/kernel/fault/fault.c index 04e4b72..4ca6648 100644 --- a/kernel/fault/fault.c +++ b/kernel/fault/fault.c @@ -10,12 +10,20 @@ #include +#if CONFIG_EMB_FAULT_HOOK_RUNTIME static emb_fault_hook_t fault_hook; /* lock: set before start or from thread context only */ void emb_fault_set_hook(emb_fault_hook_t hook) { fault_hook = hook; } +#elif EMB_HAS_WEAK +/* Bound at link time: the application's definition replaces this one. */ +EMB_WEAK void emb_fault_hook(const emb_fault_info_t *info) +{ + (void)info; +} +#endif static EMB_NORETURN void act(const emb_fault_info_t *info) { @@ -49,9 +57,13 @@ static EMB_NORETURN void raise_info(uint16_t api_id, uint8_t fault_class, uint16 info.sp = sp; info.where = where; EMBK_TRACE(EMB_TRACE_FAULT, fault_class, code, 0u); +#if CONFIG_EMB_FAULT_HOOK_RUNTIME if (fault_hook != NULL) { fault_hook(&info); } +#else + emb_fault_hook(&info); +#endif act(&info); } diff --git a/kernel/include/embk/sched.h b/kernel/include/embk/sched.h index 5af3c24..eed909f 100644 --- a/kernel/include/embk/sched.h +++ b/kernel/include/embk/sched.h @@ -24,7 +24,9 @@ extern embk_thread_t embk_idle_thread; #define EMBK_IDLE (&embk_idle_thread) #if CONFIG_EMB_SCHED_TABLE -extern embk_thread_t *embk_thread_table[CONFIG_EMB_PRIORITY_COUNT]; /* slot = priority (ADR-036) */ +/* The thread of priority p is in slot p - 1 (ADR-036); idle, at 0, has none. */ +extern embk_thread_t *embk_thread_table[CONFIG_EMB_PRIORITY_COUNT - 1]; +#define EMBK_TABLE_SLOT(p) embk_thread_table[(unsigned)(p) - 1u] #endif void embk_sched_init(void); diff --git a/kernel/sched/sched.c b/kernel/sched/sched.c index 2bf5a0c..6972922 100644 --- a/kernel/sched/sched.c +++ b/kernel/sched/sched.c @@ -23,8 +23,8 @@ embk_thread_t embk_idle_thread; /* slim: sched/idle_context.c allocates the pref #endif #if CONFIG_EMB_SCHED_TABLE -embk_thread_t *embk_thread_table[CONFIG_EMB_PRIORITY_COUNT]; -static embk_word_t ready_set; /* bit i: embk_thread_table[i] is READY */ +embk_thread_t *embk_thread_table[CONFIG_EMB_PRIORITY_COUNT - 1]; +static embk_word_t ready_set; /* bit p: EMBK_TABLE_SLOT(p) is READY; bit 0 is never set */ #if CONFIG_EMB_MUTEX uint8_t embk_sched_raised_count; /* threads whose eff_prio != base_prio (lock: sched) */ #endif @@ -48,7 +48,7 @@ void embk_sched_init(void) { #if CONFIG_EMB_SCHED_TABLE unsigned i; - for (i = 0u; i < (unsigned)CONFIG_EMB_PRIORITY_COUNT; i++) { + for (i = 0u; i < (unsigned)CONFIG_EMB_PRIORITY_COUNT - 1u; i++) { embk_thread_table[i] = NULL; } ready_set = 0u; @@ -76,7 +76,7 @@ static void insert(embk_thread_t *t, bool ahead) { #if CONFIG_EMB_SCHED_TABLE (void)ahead; /* one thread per level: no order within a level */ - EMBK_ASSERT(embk_thread_table[t->base_prio] == t); + EMBK_ASSERT(EMBK_TABLE_SLOT(t->base_prio) == t); ready_set |= EMBK_WORD_BIT(t->base_prio); #else unsigned p = t->eff_prio; @@ -114,7 +114,7 @@ void embk_sched_remove_ready(embk_thread_t *t) bool embk_sched_is_ready(const embk_thread_t *t) { #if CONFIG_EMB_SCHED_TABLE - return (ready_set & EMBK_WORD_BIT(t->base_prio)) != 0u && embk_thread_table[t->base_prio] == t; + return (ready_set & EMBK_WORD_BIT(t->base_prio)) != 0u && EMBK_TABLE_SLOT(t->base_prio) == t; #else return embk_list_node_is_linked(&t->ready_node); #endif @@ -127,14 +127,15 @@ embk_thread_t *embk_sched_table_best(embk_word_t set) if (set == 0u) { return NULL; } - best = embk_thread_table[embk_word_highest(set)]; + EMBK_ASSERT((set & EMBK_WORD_BIT(0u)) == 0u); /* idle is never in a set */ + best = EMBK_TABLE_SLOT(embk_word_highest(set)); #if CONFIG_EMB_MUTEX if (embk_sched_raised_count != 0u) { /* bounded by CONFIG_EMB_PRIORITY_COUNT: find the highest effective priority */ embk_word_t rest = set & (embk_word_t)~EMBK_WORD_BIT(best->base_prio); while (rest != 0u) { unsigned i = embk_word_highest(rest); - embk_thread_t *c = embk_thread_table[i]; + embk_thread_t *c = EMBK_TABLE_SLOT(i); if (c->eff_prio > best->eff_prio) { best = c; } diff --git a/kernel/thread/thread.c b/kernel/thread/thread.c index fa84d4f..91fdcac 100644 --- a/kernel/thread/thread.c +++ b/kernel/thread/thread.c @@ -116,9 +116,6 @@ void embk_thread_init_idle(embk_thread_t *t, void *stack, size_t stack_size) t->tflags = EMBK_THREAD_STARTED | EMBK_THREAD_KERNEL | EMBK_THREAD_DETACHED; t->cancel_disable = UINT8_MAX; embk_thread_set_index(t, IDLE_INDEX); -#if CONFIG_EMB_SCHED_TABLE - embk_thread_table[EMB_PRIORITY_IDLE] = t; -#endif #if CONFIG_EMB_IDLE_THREAD (void)stack; (void)stack_size; @@ -200,7 +197,7 @@ emb_status_t emb_thread_init(emb_thread_storage_t *storage, const emb_thread_att EMBK_API_MISUSE(EMB_API_ID_THREAD_INIT, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); } #if CONFIG_EMB_SCHED_TABLE - if (embk_thread_table[attr->priority] != NULL) { + if (EMBK_TABLE_SLOT(attr->priority) != NULL) { embk_unlock_sched(key); return EMB_EBUSY; /* one thread per priority (ADR-036) */ } @@ -234,7 +231,7 @@ emb_status_t emb_thread_init(emb_thread_storage_t *storage, const emb_thread_att #endif emb_arch_context_init(t, attr->stack, attr->stack_size, entry, arg, false); #if CONFIG_EMB_SCHED_TABLE - embk_thread_table[attr->priority] = t; + EMBK_TABLE_SLOT(attr->priority) = t; #endif #if CONFIG_EMB_THREAD_LIST embk_list_append(&embk_thread_all, &t->all_node); @@ -562,7 +559,7 @@ emb_status_t emb_thread_set_priority(emb_thread_t thread, uint8_t priority) #if CONFIG_EMB_SCHED_TABLE if (priority != t->base_prio) { uint8_t old = t->base_prio; - if (embk_thread_table[priority] != NULL) { + if (EMBK_TABLE_SLOT(priority) != NULL) { embk_unlock_sched(key); return EMB_EBUSY; } @@ -576,8 +573,8 @@ emb_status_t emb_thread_set_priority(emb_thread_t thread, uint8_t priority) if (q != NULL) { q->set &= (embk_word_t)~EMBK_WORD_BIT(old); } - embk_thread_table[old] = NULL; - embk_thread_table[priority] = t; + EMBK_TABLE_SLOT(old) = NULL; + EMBK_TABLE_SLOT(priority) = t; t->base_prio = priority; if (q != NULL) { q->set |= EMBK_WORD_BIT(priority); @@ -829,7 +826,7 @@ emb_status_t emb_thread_destroy(emb_thread_t thread) embk_list_remove(&t->all_node); #endif #if CONFIG_EMB_SCHED_TABLE - embk_thread_table[t->base_prio] = NULL; + EMBK_TABLE_SLOT(t->base_prio) = NULL; #endif index_release(embk_thread_index(t)); t->obj.state = EMBK_OBJ_UNINIT; diff --git a/tools/fault/api-ids.json b/tools/fault/api-ids.json index 34ea76b..947507b 100644 --- a/tools/fault/api-ids.json +++ b/tools/fault/api-ids.json @@ -493,6 +493,12 @@ "name": "emb_version_string", "header": "version.h", "state": "active" + }, + { + "id": 83, + "name": "emb_fault_hook", + "header": "fault.h", + "state": "active" } ] } diff --git a/tools/fault/api-registry.json b/tools/fault/api-registry.json index 47ae748..792048d 100644 --- a/tools/fault/api-registry.json +++ b/tools/fault/api-registry.json @@ -492,6 +492,12 @@ "name": "emb_version_string", "header": "version.h", "state": "active" + }, + { + "id": 83, + "name": "emb_fault_hook", + "header": "fault.h", + "state": "active" } ] } diff --git a/tools/footprint/thresholds-avr-uno-release.json b/tools/footprint/thresholds-avr-uno-release.json index eb91d13..e1410b9 100644 --- a/tools/footprint/thresholds-avr-uno-release.json +++ b/tools/footprint/thresholds-avr-uno-release.json @@ -1,6 +1,6 @@ { - "_comment": "arduino_uno, tiny profile, release build, samples/first_execution (mutex linked). Baseline after the M1 footprint pass: kernel text 3909, static RAM 66, TCB 34. Gate: baseline plus 10 percent. The R-003 targets are gated on the avr-uno-t1 preset.", + "_comment": "arduino_uno, tiny profile, release build, samples/first_execution (mutex linked). Baselines: kernel text 3909 and TCB 34 after the M1 footprint pass (gcc-avr 7.3); static RAM 42 after the slim idle context and the idle-free thread table (2026-10-27). Gate: baseline plus 10 percent. The R-003 targets are gated on the avr-uno-t1 preset.", "kernel_text_max": 4300, - "kernel_ram_max": 73, + "kernel_ram_max": 46, "tcb_max": 37 } diff --git a/tools/footprint/thresholds-avr-uno-t1.json b/tools/footprint/thresholds-avr-uno-t1.json index 135841b..44f52d2 100644 --- a/tools/footprint/thresholds-avr-uno-t1.json +++ b/tools/footprint/thresholds-avr-uno-t1.json @@ -1,6 +1,6 @@ { - "_comment": "arduino_uno, tiny profile, release build, samples/footprint_t1 with configs/footprint-t1.conf: the R-003 targets T1 (kernel text <= 4 KB on AVR) and T2 (static RAM <= 64 bytes plus <= 32 bytes per thread) as hard limits. The 64 bytes include the idle context's control block (R-003 section 4, T2 accounting).", + "_comment": "arduino_uno, tiny profile, release build, samples/footprint_t1 with configs/footprint-t1.conf: the R-003 targets T1 (kernel text <= 4 KB on AVR) and T2 (<= 32 bytes per thread) as hard limits. Kernel static RAM, which includes the idle context (R-003 section 4, T2 accounting), is gated at its measured 39 bytes rather than the T2 target of 64, so the bytes recovered by ADR-050 and the M2 small savings are not spent silently: raise it only with the change that needs the RAM.", "kernel_text_max": 4096, - "kernel_ram_max": 64, + "kernel_ram_max": 39, "tcb_max": 32 } diff --git a/tools/footprint/thresholds-avr-uno.json b/tools/footprint/thresholds-avr-uno.json index 65516ec..ca7a27c 100644 --- a/tools/footprint/thresholds-avr-uno.json +++ b/tools/footprint/thresholds-avr-uno.json @@ -1,6 +1,6 @@ { - "_comment": "arduino_uno, tiny profile, checked build, samples/first_execution (mutex linked). Baseline after the M1 footprint pass: kernel text 4654, static RAM 68, TCB 35. Gate: baseline plus 10 percent. The R-003 targets are gated on the avr-uno-t1 preset.", + "_comment": "arduino_uno, tiny profile, checked build, samples/first_execution (mutex linked). Baselines: kernel text 4654 and TCB 35 after the M1 footprint pass (gcc-avr 7.3); static RAM 44 after the slim idle context and the idle-free thread table (2026-10-27). Gate: baseline plus 10 percent. The R-003 targets are gated on the avr-uno-t1 preset.", "kernel_text_max": 5119, - "kernel_ram_max": 75, + "kernel_ram_max": 48, "tcb_max": 38 } From 5925c1798f223e496141a7ff3b2480fa58a06658 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 18:21:19 +0100 Subject: [PATCH 09/30] docs(specs): correct five contradictions in existing contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading the specifications for the M2 primitives turned up places where accepted documents disagree with each other or with the code. They are recorded as A29 to A33 in a new AMENDMENTS-M2 page and applied in place: A29 KRN-TIM-035 required EMB_EOVERFLOW for a _until deadline out of the 32-bit range, which A2 and KRN-TIM-022 make unreachable; removed. A30 SPEC-004 §9 gave EMB_EINVAL as the release status of a blocking wait from ISR or under the scheduler lock; it is EMB_EPERM everywhere else and in EMBK_API_REQUIRE_CAN_BLOCK. A31 ADR-027 still described a binding as a bit index; it is a one-bit mask (A23). A32 KRN-NOTIF-012 cited binding cases in test_sem.c; the case is in test_notify.c. A33 KRN-NOTIF-009 is accepted but unbind is not implemented yet; the status says so until M2-03 lands. No code changes. Signed-off-by: teo1747 --- docs/architecture/06-decision-records.md | 2 +- docs/requirements/KRN-NOTIF.md | 4 ++-- docs/requirements/KRN-TIM.md | 6 +++--- docs/specs/AMENDMENTS-M2.md | 11 +++++++++++ docs/specs/SPEC-004-wait-and-wake-protocol.md | 2 +- docs/specs/index.md | 1 + 6 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 docs/specs/AMENDMENTS-M2.md diff --git a/docs/architecture/06-decision-records.md b/docs/architecture/06-decision-records.md index e449e21..e0e982d 100644 --- a/docs/architecture/06-decision-records.md +++ b/docs/architecture/06-decision-records.md @@ -324,7 +324,7 @@ Each record: context, decision, alternatives, consequences, status. **Proposed** **Alternatives.** Queue sets (extra copy, sets are queues of handles); a poll object with per-object poller lists (global lock, memory per poller); pend-multi arrays (O(objects) per wait, removed by its own authors). -**Consequences.** Multi-object wait costs nothing per object beyond a thread pointer and a bit index; no new kernel object; works from the tiny profile upward; the notification width must leave bits for the application (KRN-NOTIF-006, answers Q7). Edge semantics (set on every transition to ready, thread re-checks) mean no lost wakeups and at most one spurious pass. +**Consequences.** Multi-object wait costs nothing per object beyond a thread pointer and a one-bit mask (A31); no new kernel object; works from the tiny profile upward; the notification width must leave bits for the application (KRN-NOTIF-006, answers Q7). Edge semantics (set on every transition to ready, thread re-checks) mean no lost wakeups and at most one spurious pass. **Status.** Accepted 2026-10-07 (proposed by R-003). diff --git a/docs/requirements/KRN-NOTIF.md b/docs/requirements/KRN-NOTIF.md index e684ff5..519950c 100644 --- a/docs/requirements/KRN-NOTIF.md +++ b/docs/requirements/KRN-NOTIF.md @@ -69,7 +69,7 @@ KRN-NOTIF-001 to 006 originate in `docs/architecture/03-kernel-architecture.md` ### KRN-NOTIF-009 Binding lifecycle **Statement.** `bind` on an already bound object shall return `EMB_EEXIST`; `unbind` shall remove the binding; destroying the object shall not touch the thread's bits; a set to a terminated or restarted thread shall be dropped (detected by generation where generations are checked). **Rationale.** Deterministic ownership of the single binding slot; no dangling-pointer action. -**Status.** Accepted 2026-10-07 +**Status.** Accepted 2026-10-07. Partially implemented (A33): `EMB_EEXIST` for a second bind and dropping a set to a terminated thread; `unbind` and the target-lifetime rules are M2-03 work. **Verification.** Test: `notif/bind_twice`, `notif/unbind_rebind`, `notif/bind_target_restarted` (isolated profile). **Trace.** SPEC-006 §3.1, §3.2 @@ -91,5 +91,5 @@ KRN-NOTIF-001 to 006 originate in `docs/architecture/03-kernel-architecture.md` **Statement.** Public binding operations shall take one nonzero application-bit mask, reject zero, multiple bits, and kernel bits, and preserve the object's existing readiness. A caller shall bind before producers start or check and drain existing readiness before its first notification wait; edge-driven consumers shall drain their selected sources before waiting again. **Rationale.** Matches the M1 semaphore API and prevents confusing a bit index with a mask or waiting for an edge that already occurred. **Status.** Proposed 2026-10-14; mask validation is implemented for semaphore binding, broader lifecycle validation is pending. -**Verification.** Existing `tests/conformance/test_sem.c` binding cases; planned initial-ready, multi-item drain, and invalid-mask cases mapped individually in the traceability matrix. +**Verification.** Existing `tests/conformance/test_notify.c` case `notify_semaphore_binding_signals_ready_transition` (A32); planned initial-ready, multi-item drain, and invalid-mask cases mapped individually in the traceability matrix. **Trace.** SPEC-006 §3; A18; A23 diff --git a/docs/requirements/KRN-TIM.md b/docs/requirements/KRN-TIM.md index d15a6f6..0a9c91c 100644 --- a/docs/requirements/KRN-TIM.md +++ b/docs/requirements/KRN-TIM.md @@ -249,8 +249,8 @@ KRN-TIM-001 to 007 originate in the v0.1 specification (§7.5); KRN-TIM-008 to 0 **Trace.** SPEC-003 §11, §13 ### KRN-TIM-035 Checked-build detection for time -**Statement.** Checked builds shall detect and raise a kernel fault for arming an already armed timeout node, a blocking call from an `ISR_CONTEXT` timer callback, destroying a timer with a pending or running callback, and `emb_timer_stop_sync` from the timer's own callback; a `_until` deadline out of the 32-bit profile's range shall return `EMB_EOVERFLOW` in both build kinds. -**Rationale.** These are the time-related misuses that corrupt state silently in release builds. -**Status.** Accepted 2026-10-07 +**Statement.** Checked builds shall detect and raise a kernel fault for arming an already armed timeout node, a blocking call from an `ISR_CONTEXT` timer callback, destroying a timer with a pending or running callback, and `emb_timer_stop_sync` from the timer's own callback. +**Rationale.** These are the time-related misuses that corrupt state silently in release builds. A `_until` deadline beyond the 32-bit profile's window is not among them: it is indistinguishable from a past instant and takes the past-deadline path (A2, KRN-TIM-022). +**Status.** Accepted 2026-10-07; amended 2026-10-27 (A29): the `EMB_EOVERFLOW` clause for out-of-range `_until` deadlines removed. **Verification.** Test: `tests/conformance/misuse/time/` on the native port in both build kinds. **Trace.** SPEC-003 §12; API-015 diff --git a/docs/specs/AMENDMENTS-M2.md b/docs/specs/AMENDMENTS-M2.md new file mode 100644 index 0000000..77013f2 --- /dev/null +++ b/docs/specs/AMENDMENTS-M2.md @@ -0,0 +1,11 @@ +# Specification amendments recorded during M2 + +Corrections found while preparing the M2 primitives, where an accepted specification, requirement or decision record contradicts another or the implemented behavior. Each is applied in the affected document; this page keeps the audit trail. Numbering continues from the [M1 record](AMENDMENTS-M1.md). Decisions about the new M2 primitives themselves are recorded separately, as they are taken. + +| # | Document | Amendment | Where in the code | +|---|---|---|---| +| A29 | KRN-TIM-035, SPEC-003 §12 | The requirement's last clause, `EMB_EOVERFLOW` for a `_until` deadline out of the 32-bit profile's range, contradicts A2 and KRN-TIM-022: such a deadline is indistinguishable from a past instant and takes the past-deadline path. The clause is removed; the checked-build timer detections remain. | `kernel/thread/thread.c`, `kernel/sync/*.c`, `kernel/notify/notify.c` | +| A30 | SPEC-004 §9 | A blocking wait from an ISR, with the scheduler locked or inside the caller's critical section returns `EMB_EPERM` in release builds, as SPEC-001 §5.3, SPEC-005, SPEC-006 and SPEC-007 state and `EMBK_API_REQUIRE_CAN_BLOCK` implements; the table's `EMB_EINVAL` was wrong. | `kernel/include/embk/kernel.h` | +| A31 | ADR-027 | A binding costs a thread pointer and a one-bit mask, not a bit index (A23, KRN-NOTIF-012). | `kernel/include/embk/sync.h` | +| A32 | KRN-NOTIF-012 | The verification cited binding cases in `tests/conformance/test_sem.c`, which has none; the existing case is `notify_semaphore_binding_signals_ready_transition` in `tests/conformance/test_notify.c`. | `tests/conformance/test_notify.c` | +| A33 | KRN-NOTIF-009 | The requirement is accepted but only partly implemented: `EMB_EEXIST` for a second bind and dropping a set to a terminated thread exist; `unbind` and target-lifetime rules are M2-03 work. The status now says so. | `kernel/sync/sem.c`, `kernel/notify/notify.c` | diff --git a/docs/specs/SPEC-004-wait-and-wake-protocol.md b/docs/specs/SPEC-004-wait-and-wake-protocol.md index afe787e..627f503 100644 --- a/docs/specs/SPEC-004-wait-and-wake-protocol.md +++ b/docs/specs/SPEC-004-wait-and-wake-protocol.md @@ -272,7 +272,7 @@ Order: object, then scheduler, then timeout; never the reverse. Consequences: th | Condition | Checked build | Release build | |---|---|---| -| `block_on` with nonzero timeout from ISR, with the scheduler locked, or inside a caller's critical section | fault `EMB_FAULT_API_CONTEXT` (SPEC-001 §5.3, KRN-IRQ-001, 021) | `EMB_EINVAL` | +| `block_on` with nonzero timeout from ISR, with the scheduler locked, or inside a caller's critical section | fault `EMB_FAULT_API_CONTEXT` (SPEC-001 §5.3, KRN-IRQ-001, 021) | `EMB_EPERM` (A30) | | `block_on` while `wait_state != READY` | fault `EMB_FAULT_KERNEL_INVARIANT` | undefined (cannot happen without corruption) | | `embk_wait_wake` asserting a thread that does not wait on the given queue | fault `EMB_FAULT_KERNEL_INVARIANT` | returns false | | queue order violated after insert (`prev.prio < node.prio` in `PRIORITY_FIFO`) | fault | not checked | diff --git a/docs/specs/index.md b/docs/specs/index.md index 89cfe4a..2ff7a40 100644 --- a/docs/specs/index.md +++ b/docs/specs/index.md @@ -4,6 +4,7 @@ :maxdepth: 1 AMENDMENTS-M1 +AMENDMENTS-M2 SPEC-001-api-conventions SPEC-002-interrupts-and-critical-sections SPEC-003-time-timeouts-and-timers From 47227e65924f2b1a064976200612b08dd9e81563 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 18:35:26 +0100 Subject: [PATCH 10/30] feat(notify): unbind, and a bound thread outlives its bindings (M2-03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A semaphore's notification binding stored a raw thread pointer, and nothing tied the target's lifetime to it. With no unbind, a thread could be destroyed, and its storage reinitialized, while a semaphore still named it; the next give wrote into whatever the storage had become. - emb_sem_unbind_notify() removes the binding: EMB_OK, or EMB_ENOENT (API ID 84, KRN-NOTIF-013). - Each thread counts the bindings that name it (bind_refs). Bind refuses a terminated target (EMB_ESTATE), a kernel thread (EMB_EPERM) and a full count (EMB_ENOMEM). emb_thread_destroy() is misuse while the count is nonzero. Unbinding, or destroying the semaphore, releases the target (KRN-NOTIF-014, ADR-046). - The binding is an embk_binding_t in each ready-state object (A36). A binding signal is the notify_set trace record with the bound object as its third argument (A35). - The table profile is uniprocessor only, so its unread wait generation goes, and the byte holds the count: the AVR TCB stays at 32 bytes (A34). footprint_t1 kernel text drops 6 bytes. The reference model gains BindNotify, UnbindNotify and ThreadDestroy, with results compared through a separate status map, and the invariant that no binding names a destroyed thread. The new BINDING scenario group covers unbind racing an ISR give, a bound target's destroy, the object's destroy releasing it, terminated and INACTIVE targets, a suspended target and rebinding. The runner and bridge carry the new operations; existing encodings are unchanged, and destroyed threads are left out of the final-state comparison. KRN-NOTIF-009 is implemented for semaphores and KRN-NOTIF-012 accepted. SPEC-006 §3 documents the teardown order and SPEC-008 §8 the destroy rule. The user guide, STATUS, the checklist and the changelog are updated. Signed-off-by: teo1747 --- CHANGELOG.md | 2 + docs/STATUS.md | 2 +- docs/architecture/06-decision-records.md | 12 +++ docs/engineering/m2-checklist.md | 10 +- docs/requirements/KRN-NOTIF.md | 25 ++++- docs/specs/AMENDMENTS-M2.md | 4 + .../SPEC-006-notifications-and-work-queues.md | 7 +- docs/specs/SPEC-008-thread-lifecycle.md | 2 +- docs/user/feature-status.md | 2 +- docs/user/synchronization.md | 2 +- include/emb/api_ids.h | 1 + include/emb/sem.h | 34 +++++-- include/emb/thread.h | 5 +- kernel/include/embk/binding.h | 48 ++++++++++ kernel/include/embk/kernel.h | 18 +++- kernel/include/embk/sched.h | 3 +- kernel/include/embk/sync.h | 4 +- kernel/include/embk/thread.h | 4 + kernel/notify/notify.c | 53 ++++++++++- kernel/sched/idle_context.c | 14 ++- kernel/sync/sem.c | 34 ++++--- kernel/thread/thread.c | 3 + kernel/wait/wait.c | 2 + tests/conformance/test_notify.c | 61 +++++++++++- tests/differential/diff_runner.c | 36 ++++++- tools/fault/api-ids.json | 6 ++ tools/fault/api-registry.json | 6 ++ tools/model/bridge.py | 35 +++++-- tools/model/embmodel/__init__.py | 2 +- tools/model/embmodel/kernel.py | 93 ++++++++++++++++++- tools/model/evidence.py | 3 +- tools/model/tests/scenarios.py | 75 +++++++++++++++ tools/model/tests/test_explore.py | 56 +++++++++++ tools/toolchain/native_abi.c | 9 +- 34 files changed, 606 insertions(+), 67 deletions(-) create mode 100644 kernel/include/embk/binding.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e07f83..c3cb355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Added +- `emb_sem_unbind_notify()` (KRN-NOTIF-013). A thread counts the bindings that name it, and `emb_thread_destroy()` refuses it while any does; `emb_sem_bind_notify()` refuses terminated (`EMB_ESTATE`) and kernel (`EMB_EPERM`) targets (KRN-NOTIF-014, ADR-046). The reference model and the differential bridge cover bind, unbind and thread destroy. + - `CONFIG_EMB_OBJ_LIST`: per-type lists of active semaphores and mutexes (KRN-OBJ-011). - A `.clangd` and exported `compile_commands.json` for editor support; `.clang-tidy` disables, with reasons, the checks that contradict the coding standard. diff --git a/docs/STATUS.md b/docs/STATUS.md index d6b2a5d..2037dd8 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -11,7 +11,7 @@ This page is the authoritative inventory of implementation and available evidenc | Execution and scheduling | UP fixed priority, base bitmap/FIFO scheduler, tiny thread table, scheduler lock, thread lifecycle; idle context unreachable through handles, and slim (control-block prefix only) on tiny without an idle thread (ADR-050) | Common conformance cases; tiny has unique base priorities and restricted scheduling semantics | | Waiting and time | Shared wait protocol, sleep, deadlines, cancellation, suspend overlay, ordered timeouts | Native virtual time exercises periodic and tickless modes; embedded ports currently reject tickless | | Synchronization | Semaphores, mutexes with inheritance, ceiling, recursion and owner-death policy | Conformance and native differential bridge; inheritance and deadlock detection have a configured depth limit | -| Notifications | Per-thread bits and one semaphore-to-thread binding | Binding takes a one-bit mask; no unbind implementation; pointer lifetime remains caller responsibility | +| Notifications | Per-thread bits and one semaphore-to-thread binding, with unbind; a bound thread cannot be destroyed (ADR-046) | Binding takes a one-bit mask; other objects' bindings arrive with those objects; restart generations need the isolated profile | | Objects | Caller-owned generated storage and POINTER handles; per-type active-object lists for semaphores and mutexes (`CONFIG_EMB_OBJ_LIST`) used by init | No INDEXED or TABLE handles, capability rights, partition restart, system freeze, `emb_obj_foreach()` or debug-descriptor export | | Native port | POSIX host-thread gate, deterministic IRQ injection, virtual time | Base/tiny development workflow; no current wall-clock, asynchronous signal stress, simulated isolated profile or Win32 backend | | Embedded ports | ATmega328P; Cortex-M3/M4F; RV32IMAC and RV32IMC machine mode; boards Arduino Uno, NUCLEO-F446RE, ESP32-C3-DevKitM-1, QEMU's MPS2 and RV32 virt | QEMU jobs and EmbSIM jobs (Uno, NUCLEO-F446RE, ESP32-C3-DevKitM-1) defined in CI; no real-board timing or peripheral/HIL validation recorded here | diff --git a/docs/architecture/06-decision-records.md b/docs/architecture/06-decision-records.md index e0e982d..bedf214 100644 --- a/docs/architecture/06-decision-records.md +++ b/docs/architecture/06-decision-records.md @@ -552,3 +552,15 @@ Each record: context, decision, alternatives, consequences, status. **Proposed** **Status.** Implemented for review, 2026-10-27, as part of the M2 tiny-RAM headroom work; awaiting project-owner review. +## ADR-046 A bound thread outlives its bindings + +**Context.** A notification binding (ADR-027) stores a raw pointer to its target thread. Before M2-03 nothing tied the target's lifetime to the binding: there was no unbind, and a thread could be destroyed, and its storage reinitialized, while a semaphore still pointed at it, so the next give wrote into whatever the storage had become. SPEC-006 §3.2 left the obligation with the caller. + +**Decision.** Each thread counts the bindings that name it (`bind_refs`, one byte). `bind` increments it and refuses a terminated target (`EMB_ESTATE`), a kernel thread (`EMB_EPERM`) and a full count (`EMB_ENOMEM`). `unbind` and destroying the bound object decrement it. `emb_thread_destroy()` is misuse while it is nonzero, so a binding can never refer to destroyed thread storage. Signals to a terminated, not yet destroyed, target are still dropped. The teardown order is documented in SPEC-006 §3.2. On the table profile the byte comes from the per-thread wait generation, which nothing reads on a uniprocessor (A34). + +**Alternatives.** Scanning every object at thread destroy needs a registry, which the tiny profile does not have, and runs with interrupts masked. Unbinding automatically at thread exit would need a list of the thread's bindings. Binding by scheduler table index (SPEC-006 §8) would make a reused priority slot notify the wrong thread. Generation-tagged handles belong to the INDEXED and TABLE models. + +**Consequences.** Thread destroy costs one byte comparison; bind and unbind are O(1). The base TCB size is unchanged (the byte fills padding) and the tiny TCB stays at 32 bytes on AVR. Code that destroyed a bound thread before must now unbind or destroy the object first; checked builds fault where they used to corrupt silently. The reference model checks that no binding names a destroyed thread, and its bind, unbind and thread-destroy transitions agree with the kernel in the differential bridge. + +**Status.** Implemented for review, 2026-10-27, as M2-03; awaiting project-owner review. + diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index dc963cc..e2a6a1c 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -50,11 +50,11 @@ The current docs disclose these gaps. Correcting one must update its contract, r ## M2-03: Binding and object lifetimes -- [ ] Decide the Proposed KRN-NOTIF-012 initial-readiness contract: binding's current one-bit mask and explicit initial drain must remain unambiguous. -- [ ] Define and implement unbind/teardown with a bound target, racing give/set, suspended/terminated target, destruction and storage reuse. -- [ ] Preserve direct waiters and no-lost-wakeup recheck semantics; model the bind/unbind/destroy transitions. -- [ ] Specify admission/static storage reuse and any M2 freeze behavior needed by the supported controller. Distinguish the unimplemented TABLE/partition generation mechanism from POINTER lifetime responsibility. -- [ ] Establish shutdown ownership and producer quiescence before object/target storage is invalidated; test deterministic waiter outcomes. +- [x] Decide the Proposed KRN-NOTIF-012 initial-readiness contract: binding's current one-bit mask and explicit initial drain must remain unambiguous. Accepted: no initial edge, one validated bit. +- [x] Define and implement unbind/teardown with a bound target, racing give/set, suspended/terminated target, destruction and storage reuse. `emb_sem_unbind_notify()`; the target cannot be destroyed while bound (ADR-046, KRN-NOTIF-013/014). +- [x] Preserve direct waiters and no-lost-wakeup recheck semantics; model the bind/unbind/destroy transitions. Model operations `BindNotify`, `UnbindNotify`, `ThreadDestroy`, the BINDING scenario group and the "no binding names a destroyed thread" invariant; the bridge agrees on all native presets. +- [x] Specify admission/static storage reuse and any M2 freeze behavior needed by the supported controller. Distinguish the unimplemented TABLE/partition generation mechanism from POINTER lifetime responsibility. Storage is reused only through destroy (ADR-045); a bound thread cannot be destroyed (ADR-046); the controller needs no freeze in M2 (KRN-OBJ-004 stays with the isolated configuration). POINTER handles carry no generation. +- [x] Establish shutdown ownership and producer quiescence before object/target storage is invalidated; test deterministic waiter outcomes. The teardown order is SPEC-006 §3.2; `destroy_bound_sem_releases_target` checks the waiter's `EMB_EDESTROYED` and the released target. ## M2-04: Executed cases and bounded costs diff --git a/docs/requirements/KRN-NOTIF.md b/docs/requirements/KRN-NOTIF.md index 519950c..9f35179 100644 --- a/docs/requirements/KRN-NOTIF.md +++ b/docs/requirements/KRN-NOTIF.md @@ -69,8 +69,8 @@ KRN-NOTIF-001 to 006 originate in `docs/architecture/03-kernel-architecture.md` ### KRN-NOTIF-009 Binding lifecycle **Statement.** `bind` on an already bound object shall return `EMB_EEXIST`; `unbind` shall remove the binding; destroying the object shall not touch the thread's bits; a set to a terminated or restarted thread shall be dropped (detected by generation where generations are checked). **Rationale.** Deterministic ownership of the single binding slot; no dangling-pointer action. -**Status.** Accepted 2026-10-07. Partially implemented (A33): `EMB_EEXIST` for a second bind and dropping a set to a terminated thread; `unbind` and the target-lifetime rules are M2-03 work. -**Verification.** Test: `notif/bind_twice`, `notif/unbind_rebind`, `notif/bind_target_restarted` (isolated profile). +**Status.** Accepted 2026-10-07. Implemented for semaphores 2026-10-27 (M2-03): `EMB_EEXIST`, `emb_sem_unbind_notify()`, sets to a terminated target dropped, and the target held until unbind (KRN-NOTIF-014). Restart generations belong to the isolated profile. +**Verification.** Test: `notify_semaphore_binding_signals_ready_transition` (bind twice), `notify_unbind_stops_signals` (unbind, rebind), `notify_bound_target_cannot_be_destroyed`; model scenarios of the BINDING group; `notif/bind_target_restarted` (isolated profile, planned). **Trace.** SPEC-006 §3.1, §3.2 ### KRN-NOTIF-010 Rights in isolated profiles @@ -83,13 +83,28 @@ KRN-NOTIF-001 to 006 originate in `docs/architecture/03-kernel-architecture.md` ### KRN-NOTIF-011 Trace **Statement.** Notification set (with whether it woke the owner), wait begin and result, and binding signals shall be trace events compiled out when tracing is disabled. **Rationale.** "Who set my bit" is the first debugging question of event-driven code. -**Status.** Accepted 2026-10-07 +**Status.** Accepted 2026-10-07. A binding signal is recorded as `notify_set` whose third argument names the bound object, zero for a direct set (A35). **Verification.** Test: trace decoder reconstructs every set of the conformance run. **Trace.** SPEC-006 §7; OBS-005 ### KRN-NOTIF-012 Binding mask and initial readiness **Statement.** Public binding operations shall take one nonzero application-bit mask, reject zero, multiple bits, and kernel bits, and preserve the object's existing readiness. A caller shall bind before producers start or check and drain existing readiness before its first notification wait; edge-driven consumers shall drain their selected sources before waiting again. **Rationale.** Matches the M1 semaphore API and prevents confusing a bit index with a mask or waiting for an edge that already occurred. -**Status.** Proposed 2026-10-14; mask validation is implemented for semaphore binding, broader lifecycle validation is pending. -**Verification.** Existing `tests/conformance/test_notify.c` case `notify_semaphore_binding_signals_ready_transition` (A32); planned initial-ready, multi-item drain, and invalid-mask cases mapped individually in the traceability matrix. +**Status.** Proposed 2026-10-14; accepted 2026-10-27 (M2-03): binding never emits an initial edge, and the mask is validated as stated. +**Verification.** `tests/conformance/test_notify.c` case `notify_semaphore_binding_signals_ready_transition` (A32), which binds before any give and observes that a 1 to 2 give sets no bit; planned initial-ready, multi-item drain, and invalid-mask cases mapped individually in the traceability matrix. **Trace.** SPEC-006 §3; A18; A23 + +### KRN-NOTIF-013 Unbind +**Statement.** `emb__unbind_notify()` shall remove the object's binding in prekernel or thread context, return `EMB_ENOENT` when nothing is bound, and leave bits already set in the target unchanged; after it returns, no later ready transition of the object shall set the bit. +**Rationale.** A consumer can stop listening to a source, or move it to another thread, without destroying the object. +**Status.** Accepted 2026-10-27; implemented for semaphores. +**Verification.** Test: `notify_unbind_stops_signals`; model scenarios `unbind_then_give`, `isr_give_vs_unbind`, `rebind_to_another_thread`. +**Trace.** SPEC-006 §3.1; ADR-046 + +### KRN-NOTIF-014 Bound target lifetime +**Statement.** A thread shall count the bindings that name it; `bind` shall refuse a terminated target with `EMB_ESTATE`, a kernel thread with `EMB_EPERM` and a full count with `EMB_ENOMEM`; `emb_thread_destroy()` shall be misuse (`EMB_EBUSY`) while the count is nonzero; unbinding or destroying the bound object shall release the target. +**Rationale.** In the POINTER model a binding is a raw thread reference; refusing destroy while one exists is the only way to make a dangling target impossible without a registry. +**Status.** Accepted 2026-10-27; implemented for semaphores. +**Verification.** Test: `notify_bound_target_cannot_be_destroyed`, `notify_bind_refuses_a_terminated_target`; model invariant "no binding names a destroyed thread" and scenarios `destroy_target_while_bound`, `destroy_bound_sem_releases_target`, `bind_terminated_target`. +**Trace.** SPEC-006 §3.2; SPEC-008 §8; ADR-046 + diff --git a/docs/specs/AMENDMENTS-M2.md b/docs/specs/AMENDMENTS-M2.md index 77013f2..6c3df39 100644 --- a/docs/specs/AMENDMENTS-M2.md +++ b/docs/specs/AMENDMENTS-M2.md @@ -9,3 +9,7 @@ Corrections found while preparing the M2 primitives, where an accepted specifica | A31 | ADR-027 | A binding costs a thread pointer and a one-bit mask, not a bit index (A23, KRN-NOTIF-012). | `kernel/include/embk/sync.h` | | A32 | KRN-NOTIF-012 | The verification cited binding cases in `tests/conformance/test_sem.c`, which has none; the existing case is `notify_semaphore_binding_signals_ready_transition` in `tests/conformance/test_notify.c`. | `tests/conformance/test_notify.c` | | A33 | KRN-NOTIF-009 | The requirement is accepted but only partly implemented: `EMB_EEXIST` for a second bind and dropping a set to a terminated thread exist; `unbind` and target-lifetime rules are M2-03 work. The status now says so. | `kernel/sync/sem.c`, `kernel/notify/notify.c` | +| A34 | A15, SPEC-004 §11 | The table profile drops the per-thread wait generation as well: it is uniprocessor only, and on a uniprocessor the timeout is disarmed in the section that ends the wait, so nothing reads it. The byte holds the binding reference count (A36, ADR-046) and the tiny TCB stays at 32 bytes on AVR. | `kernel/include/embk/kernel.h`, `kernel/wait/wait.c` | +| A35 | SPEC-006 §7, KRN-NOTIF-011, SPEC-015 | There is no separate `bind_signal` event: a binding's signal is the `notify_set` record with the bound object as its third argument, which a direct set leaves zero. Existing trace decoders and archived captures stay valid. | `kernel/notify/notify.c` | +| A36 | SPEC-009 §2, SPEC-006 §3.1 | The binding lives in each ready-state object as an `embk_binding_t`, not in the common object header, so objects that cannot be bound pay nothing for it. | `kernel/include/embk/binding.h` | +| A37 | SPEC-006 §3.1 | `unbind` is `prekernel thread`, like `bind`, so setup and teardown code can run before the kernel starts. | `kernel/sync/sem.c` | diff --git a/docs/specs/SPEC-006-notifications-and-work-queues.md b/docs/specs/SPEC-006-notifications-and-work-queues.md index 5118013..2a6d039 100644 --- a/docs/specs/SPEC-006-notifications-and-work-queues.md +++ b/docs/specs/SPEC-006-notifications-and-work-queues.md @@ -78,10 +78,10 @@ Every waitable object type whose readiness is a state (semaphore count, event bi ```c emb_status_t emb__bind_notify(emb__t obj, emb_thread_t t, emb_notify_bits_t bit); /* @ctx prekernel thread; one nonzero application bit */ -emb_status_t emb__unbind_notify(emb__t obj); /* @ctx thread */ +emb_status_t emb__unbind_notify(emb__t obj); /* @ctx prekernel thread (A37) */ ``` -`bind` fails with `EMB_EEXIST` when the object is already bound (unbind first), `EMB_EINVAL` for a kernel bit, `EMB_EPERM` in isolated profiles when the caller lacks the object's `BIND` right or the thread's `NOTIFY` right. The binding is stored in the object: one thread reference and one nonzero single-bit mask within `EMB_NOTIFY_APP_MASK`. Public callers pass `UINT32_C(1) << index`, not the index; the model scenario encoding may use an index internally. M1 implements only semaphore binding; unbind and other object bindings remain planned. +`bind` fails with `EMB_EEXIST` when the object is already bound (unbind first), `EMB_ESTATE` for a terminated target, `EMB_EPERM` for a kernel thread and, in isolated profiles, when the caller lacks the object's `BIND` right or the thread's `NOTIFY` right, `EMB_ENOMEM` when 255 bindings already name the target, and `EMB_EINVAL` for a kernel bit. `unbind` returns `EMB_OK`, or `EMB_ENOENT` when nothing is bound. The binding is stored in the object as an `embk_binding_t` (A36): one thread reference and one nonzero single-bit mask within `EMB_NOTIFY_APP_MASK`. Public callers pass `UINT32_C(1) << index`, not the index; the model scenario encoding may use an index internally. Semaphore binding and unbind are implemented (M2-03); each further ready-state object brings its binding with it. ### 3.2 Semantics @@ -90,7 +90,8 @@ emb_status_t emb__unbind_notify(emb__t obj); - Binding does not signal an object that is already ready. Setup must bind before producers start or drain/recheck existing readiness before the first wait. Edge notifications require draining the selected source until not ready; consuming only one item can leave ready data without another edge. The bound thread waits on its mask, then performs the operation with `EMB_NO_WAIT` on each object whose bit is set and treats `EMB_ETIMEDOUT` as "another consumer was faster", clearing nothing it did not consume (the bit was already cleared by the `CLEAR` wait; the next transition sets it again). Edge semantics plus re-check give **no lost ready transition and at most one spurious pass per transition under the bind/check/drain protocol** (KRN-NOTIF-008). - Direct waiters on the object are unaffected (KRN-NOTIF-005): they are served first, by hand-off, and such a transition sets no bit. - One binding per object in 1.0 (§12.3); a second consumer of the same object uses a direct wait. -- Destroying a bound object does not touch the thread's bits; a notification to a terminated target is dropped in M1, but its storage must remain valid while a binding references it. Pointer mode cannot safely detect freed or reused storage; it is not a stale-reference security mechanism. Planned unbind must precede reuse, and generation-checked profiles must reject a changed target incarnation. Rebinding after thread restart is the application's job. +- **Target lifetime (ADR-046).** Each thread counts the bindings that name it; `emb_thread_destroy()` refuses a thread while that count is nonzero (fault `EMB_FAULT_API_LIFECYCLE` when checked, `EMB_EBUSY` in release), so a binding never refers to destroyed or reused thread storage. `unbind`, and destroying the bound object, release the target. Destroying a bound object does not touch the thread's bits. A notification to a terminated, not yet destroyed, target is dropped. Generation-checked profiles must still reject a changed target incarnation; rebinding after thread restart is the application's job. +- **Teardown order.** (1) Quiesce the producers (interrupt sources, giving threads) or accept that their gives still count. (2) `unbind`, or destroy the object; with `EMB_OBJ_ABORT_WAITERS` its direct waiters leave with `EMB_EDESTROYED`. (3) Let the target exit, then join it (or have it detached). (4) Destroy the thread. A thread can be destroyed only after steps 2 and 3; the object only once no thread waits on it without `ABORT_WAITERS`. No freeze mechanism is needed for this order in M2 (KRN-OBJ-004 belongs to the isolated configuration). ### 3.3 Worked pattern: one thread, three sources diff --git a/docs/specs/SPEC-008-thread-lifecycle.md b/docs/specs/SPEC-008-thread-lifecycle.md index 78afcd8..92bfc1f 100644 --- a/docs/specs/SPEC-008-thread-lifecycle.md +++ b/docs/specs/SPEC-008-thread-lifecycle.md @@ -106,7 +106,7 @@ The exiting thread's stack is in use until step 5. Interrupts taken between step ## 8. Destroy -`destroy(t)` requires `INACTIVE` (never started), or `TERMINATED` and reusable (joined or detached, switch-out complete). Anything else is misuse (`EMB_EBUSY`). Destroy removes the thread from the all-threads list, bumps the handle generation (KRN-OBJ-002), and returns the storage to the owner. A thread that is still a mutex owner cannot be `TERMINATED` (step 2 of §5 released them), so no ownership survives destroy. After destroy, stale handles fail with `EMB_ESTALE` in profiles that check generations and are undefined in the tiny profile, as for every object (SPEC-009). +`destroy(t)` requires `INACTIVE` (never started), or `TERMINATED` and reusable (joined or detached, switch-out complete), and no notification binding naming the thread (SPEC-006 §3.2, ADR-046). Anything else is misuse (`EMB_EBUSY`). Destroy removes the thread from the all-threads list, bumps the handle generation (KRN-OBJ-002), and returns the storage to the owner. A thread that is still a mutex owner cannot be `TERMINATED` (step 2 of §5 released them), so no ownership survives destroy. After destroy, stale handles fail with `EMB_ESTALE` in profiles that check generations and are undefined in the tiny profile, as for every object (SPEC-009). ## 9. Thread-local storage slots diff --git a/docs/user/feature-status.md b/docs/user/feature-status.md index 497bfd5..e09126f 100644 --- a/docs/user/feature-status.md +++ b/docs/user/feature-status.md @@ -8,7 +8,7 @@ This guide describes the current 0.2 development implementation. The authoritati | Protect or signal work | Mutexes, semaphores, per-thread notifications | Event groups, condition variables, barriers | | Transfer payloads | Application-owned data with an explicit synchronization protocol | Message queues, pipes, buffer pools | | Run periodic work | A thread using absolute sleep deadlines | Software timers and system work queues | -| Wait on several readiness sources | Semaphore binding plus notification bits and rechecking | General object bindings, unbind, restart-safe target lifetime | +| Wait on several readiness sources | Semaphore binding and unbind plus notification bits and rechecking; a bound thread cannot be destroyed | General object bindings, restart-safe target lifetime | | Diagnose failures | State/priority queries, optional stack estimates, synchronous trace and fault hooks | Retained crash formats, CTF rings, full host tooling | | Develop without a board | Native Linux/POSIX virtual time and conformance suite | Physical-board timing, peripheral drivers and HIL evidence | | Deploy a controller platform | Embedded kernel ports and QEMU configurations | Complete board/peripheral/application integration and release qualification | diff --git a/docs/user/synchronization.md b/docs/user/synchronization.md index 20ce502..7c1f2dc 100644 --- a/docs/user/synchronization.md +++ b/docs/user/synchronization.md @@ -107,7 +107,7 @@ Binding takes a **one-bit mask**, such as `EMB_NOTIFY_BIT(0)`, rather than the i Drain with `emb_sem_take(..., EMB_NO_WAIT)` until `EMB_ETIMEDOUT`, then wait on the notification with CLEAR. A give between the empty check and the wait leaves the bit set, so the wait observes it. A previously set bit can cause a harmless extra pass; recheck the semaphore after every wake. The count is the source of truth. A notification is not proof that a unit is still available, and `emb_sem_count()` does not reserve one. Direct semaphore waiters retain their direct hand-off behavior. -There is no unbind API yet. Keep the bound target's storage and lifetime valid until the semaphore is destroyed; do not destroy/reinitialize it while another caller can give to the bound semaphore. Destroying the semaphore does not clear the target's notification bits. Planned binding/restart guarantees are listed in [Project status](../STATUS.md). +`emb_sem_unbind_notify()` removes the binding. While a binding names a thread, `emb_thread_destroy()` refuses it (a fault in checked builds, `EMB_EBUSY` in release), so tear down in this order: stop the producers, unbind or destroy the semaphore, let the consumer exit and join it, then destroy the thread. Neither unbinding nor destroying the semaphore clears the target's notification bits. Binding a thread that has already terminated returns `EMB_ESTATE`. ## Wait results and execution context diff --git a/include/emb/api_ids.h b/include/emb/api_ids.h index 307548a..513ca4f 100644 --- a/include/emb/api_ids.h +++ b/include/emb/api_ids.h @@ -87,5 +87,6 @@ #define EMB_API_ID_TRACE_SET_SINK 0x0051u #define EMB_API_ID_VERSION_STRING 0x0052u #define EMB_API_ID_FAULT_HOOK 0x0053u +#define EMB_API_ID_SEM_UNBIND_NOTIFY 0x0054u #endif /* EMB_API_IDS_H */ diff --git a/include/emb/sem.h b/include/emb/sem.h index ae488a8..1811cca 100644 --- a/include/emb/sem.h +++ b/include/emb/sem.h @@ -187,7 +187,8 @@ emb_sem_count_t emb_sem_count(emb_sem_t sem); /** * emb_sem_bind_notify() - Set @bit in @thread when the count goes from 0 to 1 (ADR-027). * @sem: Initialized semaphore with no existing binding. - * @thread: Initialized target retained for the entire binding lifetime; no unbind API exists yet. + * @thread: Initialized, not terminated, application thread. It cannot be destroyed while + * the binding exists: unbind or destroy the semaphore first (ADR-046). * @bit: One application-bit mask, e.g. EMB_NOTIFY_BIT(0), not a bit index. * * @ctx prekernel thread @@ -195,16 +196,37 @@ emb_sem_count_t emb_sem_count(emb_sem_t sem); * @time O(1) * @owns none * @config CONFIG_EMB_SEM CONFIG_EMB_NOTIFY - * @req KRN-SYNC-035 KRN-NOTIF-005 KRN-NOTIF-009 + * @req KRN-SYNC-035 KRN-NOTIF-005 KRN-NOTIF-009 KRN-NOTIF-014 * @since 0.2 * @stable yes * - * Return: EMB_OK; EMB_EEXIST when already bound; EMB_EINVAL for bad handles, - * a zero/multi-bit mask or a kernel bit. Binding does not signal an - * already-positive count: inspect/drain initial readiness before waiting. - * Keep the target thread alive until the semaphore is destroyed. + * Return: EMB_OK; EMB_EEXIST when already bound; EMB_ESTATE for a terminated target; + * EMB_EPERM for a kernel thread; EMB_ENOMEM when 255 bindings already name the + * target; EMB_EINVAL for bad handles, a zero/multi-bit mask or a kernel bit. + * Binding does not signal an already-positive count: inspect/drain initial + * readiness before waiting. */ emb_status_t emb_sem_bind_notify(emb_sem_t sem, emb_thread_t thread, emb_notify_bits_t bit); + +/** + * emb_sem_unbind_notify() - Remove the semaphore's notification binding. + * @sem: Initialized semaphore. Later 0 to 1 transitions set no bit; bits already set in + * the target stay set. + * + * @ctx prekernel thread + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_SEM CONFIG_EMB_NOTIFY + * @req KRN-NOTIF-009 KRN-NOTIF-013 + * @since 0.2 + * @stable yes + * + * Return: EMB_OK; EMB_ENOENT when not bound; EMB_EINVAL for a bad handle. Unbinding + * releases the target thread for emb_thread_destroy(); destroying the semaphore + * does too. + */ +emb_status_t emb_sem_unbind_notify(emb_sem_t sem); #endif #ifdef __cplusplus diff --git a/include/emb/thread.h b/include/emb/thread.h index 4a63e1e..cf24e19 100644 --- a/include/emb/thread.h +++ b/include/emb/thread.h @@ -539,11 +539,12 @@ void emb_thread_foreach(emb_thread_visit_t cb, void *arg); * @time O(1) * @owns gives:storage * @config - * @req KRN-OBJ-001 KRN-THR-024 + * @req KRN-OBJ-001 KRN-THR-024 KRN-NOTIF-014 * @since 0.2 * @stable yes * - * Return: EMB_OK; EMB_EBUSY (misuse) for any other state; EMB_EINVAL. + * Return: EMB_OK; EMB_EBUSY (misuse) for any other state, or while a notification binding + * names the thread (unbind or destroy the bound object first, ADR-046); EMB_EINVAL. */ emb_status_t emb_thread_destroy(emb_thread_t thread); diff --git a/kernel/include/embk/binding.h b/kernel/include/embk/binding.h new file mode 100644 index 0000000..ed9294f --- /dev/null +++ b/kernel/include/embk/binding.h @@ -0,0 +1,48 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* + * Notification bindings of ready-state objects (SPEC-006 §3, ADR-027, ADR-046). An object + * names one (thread, bit) pair; the thread counts the bindings that name it, and cannot be + * destroyed while any does, so a binding never outlives its target. Lock: the object's, + * which is the one critical section on a uniprocessor. + */ +#ifndef EMBK_BINDING_H +#define EMBK_BINDING_H + +#include +#include + +#if CONFIG_EMB_NOTIFY +typedef struct embk_binding { + embk_thread_t *thread; /* NULL when unbound */ + emb_notify_bits_t bit; /* one application bit (A23) */ +} embk_binding_t; + +static EMB_INLINE void embk_binding_init(embk_binding_t *b) +{ + b->thread = NULL; + b->bit = 0u; +} + +/* Bind @t's @bit, already validated, lock held. EMB_EEXIST when bound; EMB_ESTATE for a + * terminated target; EMB_EPERM for a kernel thread; EMB_ENOMEM when @t's count is full. */ +emb_status_t embk_binding_bind(embk_binding_t *b, embk_thread_t *t, emb_notify_bits_t bit); + +/* Remove the binding and release its target, lock held; false when there was none. */ +bool embk_binding_unbind(embk_binding_t *b); + +/* @obj became ready for its bound operation without a hand-off, lock held. */ +static EMB_INLINE void embk_binding_signal(const embk_binding_t *b, const void *obj) +{ + if (b->thread != NULL) { +#if CONFIG_EMB_TRACE + embk_notify_set_bound(b->thread, b->bit, obj); +#else + (void)obj; + embk_notify_set_locked(b->thread, b->bit); +#endif + } +} +#endif /* CONFIG_EMB_NOTIFY */ + +#endif /* EMBK_BINDING_H */ diff --git a/kernel/include/embk/kernel.h b/kernel/include/embk/kernel.h index 36113b4..550c184 100644 --- a/kernel/include/embk/kernel.h +++ b/kernel/include/embk/kernel.h @@ -149,7 +149,13 @@ struct embk_thread { #if CONFIG_EMB_CHECKED uint8_t lock_depth; /* the critical-section depth of this thread's context while switched out */ #endif - embk_wait_gen_t wait_gen; + /* the slim idle context ends here (EMBK_IDLE_BYTES, ADR-050) */ +#if !CONFIG_EMB_SCHED_TABLE + embk_wait_gen_t wait_gen; /* the table profile is uniprocessor only: nothing reads it (A15) */ +#endif +#if CONFIG_EMB_NOTIFY + uint8_t bind_refs; /* objects whose notification binding names this thread (ADR-046) */ +#endif uintptr_t wake_data; /* the waker's datum; the exit code once TERMINATED (SPEC-008 §5) */ embk_wait_queue_t *wait_queue; /* the queue the thread waits in, or NULL */ #if !CONFIG_EMB_SCHED_TABLE @@ -178,6 +184,16 @@ struct embk_thread { #define EMBK_STACK_GUARD_BYTES 8u +/* The prefix of a control block the idle context uses (ADR-050). Defined for the table + * profile, the only one with a slim idle context. */ +#if CONFIG_EMB_SCHED_TABLE +#if CONFIG_EMB_NOTIFY +#define EMBK_IDLE_BYTES offsetof(embk_thread_t, bind_refs) +#else +#define EMBK_IDLE_BYTES offsetof(embk_thread_t, wake_data) +#endif +#endif + static EMB_INLINE uint8_t embk_thread_index(const embk_thread_t *t) { #if EMBK_THREAD_INDEX_PACKED diff --git a/kernel/include/embk/sched.h b/kernel/include/embk/sched.h index eed909f..bc3a718 100644 --- a/kernel/include/embk/sched.h +++ b/kernel/include/embk/sched.h @@ -18,8 +18,7 @@ * blocks, times out, takes notifications, joins or changes priority, so only the control * block's prefix that the scheduler, the switch path and diagnostics read (arch through * lock_depth) is allocated, by sched/idle_context.c; nothing reads past it (ADR-050). */ -#define EMBK_IDLE_SLIM (CONFIG_EMB_SCHED_TABLE && !CONFIG_EMB_IDLE_THREAD) -#define EMBK_IDLE_BYTES offsetof(embk_thread_t, wait_gen) +#define EMBK_IDLE_SLIM (CONFIG_EMB_SCHED_TABLE && !CONFIG_EMB_IDLE_THREAD) extern embk_thread_t embk_idle_thread; #define EMBK_IDLE (&embk_idle_thread) diff --git a/kernel/include/embk/sync.h b/kernel/include/embk/sync.h index 235e38f..c8c3877 100644 --- a/kernel/include/embk/sync.h +++ b/kernel/include/embk/sync.h @@ -6,6 +6,7 @@ #include +#include #include #if CONFIG_EMB_SEM @@ -15,8 +16,7 @@ typedef struct embk_sem { emb_sem_count_t count; emb_sem_count_t max; #if CONFIG_EMB_NOTIFY - embk_thread_t *bind_thread; /* ADR-027: NULL when unbound */ - emb_notify_bits_t bind_bit; + embk_binding_t binding; /* ADR-027: signaled on the 0 to 1 transition */ #endif #if CONFIG_EMB_OBJ_LIST embk_list_node_t all_node; /* in embk_sem_all from init to destroy */ diff --git a/kernel/include/embk/thread.h b/kernel/include/embk/thread.h index 46d1f84..8ea4115 100644 --- a/kernel/include/embk/thread.h +++ b/kernel/include/embk/thread.h @@ -21,6 +21,10 @@ extern embk_list_t embk_thread_all; /* lock: scheduler domain */ #if CONFIG_EMB_NOTIFY /* OR @bits into @t's word and wake it if satisfied; lock held; kernel bits allowed. */ void embk_notify_set_locked(embk_thread_t *t, emb_notify_bits_t bits); +#if CONFIG_EMB_TRACE +/* The same for a binding: the trace record names the bound object @obj (SPEC-006 §7). */ +void embk_notify_set_bound(embk_thread_t *t, emb_notify_bits_t bits, const void *obj); +#endif #endif #endif /* EMBK_THREAD_H */ diff --git a/kernel/notify/notify.c b/kernel/notify/notify.c index ca2864e..02fb97f 100644 --- a/kernel/notify/notify.c +++ b/kernel/notify/notify.c @@ -3,6 +3,7 @@ /* Thread notifications (SPEC-006 §2): level-sensitive bits with hand-off on satisfaction. */ #include +#include #include #include #include @@ -48,18 +49,66 @@ emb_status_t emb_notify_set(emb_thread_t thread, emb_notify_bits_t bits) return EMB_OK; } -void embk_notify_set_locked(embk_thread_t *t, emb_notify_bits_t bits) +/* @source: the bound object, NULL for a direct set; only the trace reads it. */ +static void notify_set(embk_thread_t *t, emb_notify_bits_t bits, const void *source) { + (void)source; if ((t->tflags & EMBK_THREAD_TERMINATED) != 0u) { return; /* dropped: a terminated thread has no notification word (SPEC-008 §5) */ } t->notify_bits |= bits; - EMBK_TRACE(EMB_TRACE_NOTIFY_SET, embk_thread_index(t), bits, 0u); + EMBK_TRACE(EMB_TRACE_NOTIFY_SET, embk_thread_index(t), bits, source); if (t->wait_queue == &t->self_q && t->wait_reason == EMB_WAIT_REASON_NOTIFY && satisfied(t)) { (void)embk_wait_wake(t, EMBK_WAKE_SATISFIED, (uintptr_t)consume(t)); } } +void embk_notify_set_locked(embk_thread_t *t, emb_notify_bits_t bits) +{ + notify_set(t, bits, NULL); +} + +#if CONFIG_EMB_TRACE +void embk_notify_set_bound(embk_thread_t *t, emb_notify_bits_t bits, const void *obj) +{ + notify_set(t, bits, obj); +} +#endif + +/* ---- bindings (SPEC-006 §3, ADR-046) --------------------------------------------- */ + +emb_status_t embk_binding_bind(embk_binding_t *b, embk_thread_t *t, emb_notify_bits_t bit) +{ + if (b->thread != NULL) { + return EMB_EEXIST; + } + if ((t->tflags & EMBK_THREAD_TERMINATED) != 0u) { + return EMB_ESTATE; /* its sets would be dropped and its destroy would wait on us */ + } + if ((t->tflags & EMBK_THREAD_KERNEL) != 0u) { + return EMB_EPERM; /* kernel threads own their notification bits */ + } + if (t->bind_refs == UINT8_MAX) { + return EMB_ENOMEM; + } + t->bind_refs++; + b->thread = t; + b->bit = bit; + return EMB_OK; +} + +bool embk_binding_unbind(embk_binding_t *b) +{ + if (b->thread == NULL) { + return false; + } + EMBK_ASSERT(b->thread->bind_refs != 0u); + b->thread->bind_refs--; + b->thread = NULL; + b->bit = 0u; + return true; +} + static emb_status_t wait_common(uint16_t api_id, emb_notify_bits_t mask, uint8_t mode, emb_tick_t deadline, bool nowait, emb_notify_bits_t *out_bits) { diff --git a/kernel/sched/idle_context.c b/kernel/sched/idle_context.c index 267efdb..e1a8b16 100644 --- a/kernel/sched/idle_context.c +++ b/kernel/sched/idle_context.c @@ -15,18 +15,16 @@ #include -#define IDLE_BYTES offsetof(embk_thread_t, wait_gen) - /* Every field the kernel or a port reads or writes for the idle context lies in front. */ EMB_STATIC_ASSERT(offsetof(embk_thread_t, arch) == 0u, "idle prefix: arch"); -EMB_STATIC_ASSERT(offsetof(embk_thread_t, obj) < IDLE_BYTES, "idle prefix: obj"); -EMB_STATIC_ASSERT(offsetof(embk_thread_t, tflags) < IDLE_BYTES, "idle prefix: tflags"); -EMB_STATIC_ASSERT(offsetof(embk_thread_t, cancel_disable) < IDLE_BYTES, "idle prefix"); +EMB_STATIC_ASSERT(offsetof(embk_thread_t, obj) < EMBK_IDLE_BYTES, "idle prefix: obj"); +EMB_STATIC_ASSERT(offsetof(embk_thread_t, tflags) < EMBK_IDLE_BYTES, "idle prefix: tflags"); +EMB_STATIC_ASSERT(offsetof(embk_thread_t, cancel_disable) < EMBK_IDLE_BYTES, "idle prefix"); #if !EMBK_THREAD_INDEX_PACKED -EMB_STATIC_ASSERT(offsetof(embk_thread_t, index) < IDLE_BYTES, "idle prefix: index"); +EMB_STATIC_ASSERT(offsetof(embk_thread_t, index) < EMBK_IDLE_BYTES, "idle prefix: index"); #endif #if CONFIG_EMB_CHECKED -EMB_STATIC_ASSERT(offsetof(embk_thread_t, lock_depth) < IDLE_BYTES, "idle prefix: lock_depth"); +EMB_STATIC_ASSERT(offsetof(embk_thread_t, lock_depth) < EMBK_IDLE_BYTES, "idle prefix: lock_depth"); #endif -alignas(embk_thread_t) unsigned char embk_idle_thread[IDLE_BYTES]; +alignas(embk_thread_t) unsigned char embk_idle_thread[EMBK_IDLE_BYTES]; diff --git a/kernel/sync/sem.c b/kernel/sync/sem.c index abe8dd9..bfd6409 100644 --- a/kernel/sync/sem.c +++ b/kernel/sync/sem.c @@ -87,8 +87,7 @@ emb_status_t emb_sem_init(emb_sem_storage_t *storage, const emb_sem_attr_t *attr s->count = attr->initial; s->max = attr->max; #if CONFIG_EMB_NOTIFY - s->bind_thread = NULL; - s->bind_bit = 0u; + embk_binding_init(&s->binding); #endif #if CONFIG_EMB_OBJ_LIST embk_list_append(&embk_sem_all, &s->all_node); @@ -112,6 +111,9 @@ emb_status_t emb_sem_destroy(emb_sem_t sem) } (void)embk_wait_flush(&s->waiters, EMBK_WAKE_DESTROYED); } +#if CONFIG_EMB_NOTIFY + (void)embk_binding_unbind(&s->binding); /* releases the target; its bits stay (§3.2) */ +#endif #if CONFIG_EMB_OBJ_LIST embk_list_remove(&s->all_node); #endif @@ -187,9 +189,8 @@ emb_status_t emb_sem_give(emb_sem_t sem) s->count++; EMBK_TRACE(EMB_TRACE_SEM_GIVE, 0xFFu, s, s->count); #if CONFIG_EMB_NOTIFY - if (s->count == 1u && s->bind_thread != NULL) { - embk_notify_set_locked(s->bind_thread, - s->bind_bit); /* ADR-027: the 0 to 1 transition */ + if (s->count == 1u) { + embk_binding_signal(&s->binding, s); /* ADR-027: the 0 to 1 transition */ } #endif } @@ -210,6 +211,7 @@ emb_status_t emb_sem_bind_notify(emb_sem_t sem, emb_thread_t thread, emb_notify_ embk_sem_t *s = sem_from_handle(sem); embk_thread_t *t = embk_thread_from_handle(thread); emb_irq_key_t key; + emb_status_t st; EMBK_API_REQUIRE_NOT_ISR( EMB_API_ID_SEM_BIND_NOTIFY); /* bindings are set up before the kernel runs too (A18) */ EMBK_API_REQUIRE(EMB_API_ID_SEM_BIND_NOTIFY, s != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); @@ -219,14 +221,22 @@ emb_status_t emb_sem_bind_notify(emb_sem_t sem, emb_thread_t thread, emb_notify_ (bit & (emb_notify_bits_t)~EMB_NOTIFY_APP_MASK) == 0u, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 3u); key = embk_lock_object(s); - if (s->bind_thread != NULL) { - embk_unlock_object(s, key); - return EMB_EEXIST; - } - s->bind_thread = t; - s->bind_bit = bit; + st = embk_binding_bind(&s->binding, t, bit); embk_unlock_object(s, key); - return EMB_OK; + return st; +} + +emb_status_t emb_sem_unbind_notify(emb_sem_t sem) +{ + embk_sem_t *s = sem_from_handle(sem); + emb_irq_key_t key; + bool bound; + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_SEM_UNBIND_NOTIFY); + EMBK_API_REQUIRE(EMB_API_ID_SEM_UNBIND_NOTIFY, s != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + key = embk_lock_object(s); + bound = embk_binding_unbind(&s->binding); + embk_unlock_object(s, key); + return bound ? EMB_OK : EMB_ENOENT; } #endif diff --git a/kernel/thread/thread.c b/kernel/thread/thread.c index 91fdcac..26e9b31 100644 --- a/kernel/thread/thread.c +++ b/kernel/thread/thread.c @@ -818,6 +818,9 @@ emb_status_t emb_thread_destroy(emb_thread_t thread) ok = (t->tflags & EMBK_THREAD_STARTED) == 0u || ((t->tflags & EMBK_THREAD_TERMINATED) != 0u && (t->tflags & EMBK_THREAD_JOINED) != 0u && emb_arch_switch_out_done(t)); +#if CONFIG_EMB_NOTIFY + ok = ok && t->bind_refs == 0u; /* a binding still names it: unbind first (ADR-046) */ +#endif if (!ok) { embk_unlock_sched(key); EMBK_API_MISUSE(EMB_API_ID_THREAD_DESTROY, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); diff --git a/kernel/wait/wait.c b/kernel/wait/wait.c index 016f519..9e04025 100644 --- a/kernel/wait/wait.c +++ b/kernel/wait/wait.c @@ -146,7 +146,9 @@ bool embk_wait_wake(embk_thread_t *t, embk_wait_result_t result, uintptr_t data) t->wait_queue = NULL; embk_thread_set_wake_result(t, result); /* KRN-WAIT-005: result before READY */ t->wake_data = data; +#if !CONFIG_EMB_SCHED_TABLE t->wait_gen++; /* every later timeout, cancel, or flush for this wait is stale */ +#endif was = t->wait_state; t->wait_state = EMBK_WAIT_READY; #if CONFIG_EMB_MUTEX diff --git a/tests/conformance/test_notify.c b/tests/conformance/test_notify.c index dca2977..5dd4b4b 100644 --- a/tests/conformance/test_notify.c +++ b/tests/conformance/test_notify.c @@ -1,6 +1,6 @@ /* SPDX-License-Identifier: Apache-2.0 */ /* Copyright 2026 Junior Deogracias */ -/* Notification conformance (SPEC-006 §2, §3; KRN-NOTIF-001 to 006). */ +/* Notification conformance (SPEC-006 §2, §3; KRN-NOTIF-001 to 006, 009, 013, 014). */ #include #define B0 EMB_NOTIFY_BIT(0) @@ -119,4 +119,63 @@ EMB_TEST(notify_semaphore_binding_signals_ready_transition) EMB_ASSERT_OK(emb_sem_take(sem, EMB_NO_WAIT)); EMB_ASSERT_OK(emb_sem_destroy(sem)); } + +EMB_TEST(notify_unbind_stops_signals) +{ + emb_sem_t sem; + emb_notify_bits_t got = 0u; + EMB_TEST_REQ("KRN-NOTIF-009", "KRN-NOTIF-013"); + EMB_ASSERT_OK(emb_sem_init(&sem_storage, NULL, &sem)); + EMB_ASSERT_STATUS(emb_sem_unbind_notify(sem), EMB_ENOENT); + EMB_ASSERT_OK(emb_sem_bind_notify(sem, emb_thread_self(), B0)); + EMB_ASSERT_OK(emb_sem_unbind_notify(sem)); + EMB_ASSERT_STATUS(emb_sem_unbind_notify(sem), EMB_ENOENT); + EMB_ASSERT_OK(emb_sem_give(sem)); /* 0 -> 1 while unbound: no bit */ + EMB_ASSERT_STATUS(emb_notify_wait(B0, EMB_NOTIFY_ANY, EMB_NO_WAIT, &got), EMB_ETIMEDOUT); + EMB_ASSERT_OK(emb_sem_take(sem, EMB_NO_WAIT)); + EMB_ASSERT_OK(emb_sem_bind_notify(sem, emb_thread_self(), B1)); /* rebind */ + EMB_ASSERT_OK(emb_sem_give(sem)); + EMB_ASSERT_OK(emb_notify_wait(B1, EMB_NOTIFY_ANY | EMB_NOTIFY_CLEAR, EMB_NO_WAIT, &got)); + EMB_ASSERT_EQ(got, B1); + EMB_ASSERT_OK(emb_sem_take(sem, EMB_NO_WAIT)); + EMB_ASSERT_OK(emb_sem_destroy(sem)); +} + +static void wait_b0(void *arg) +{ + emb_notify_bits_t got = 0u; + (void)arg; + emb_test_mark(emb_notify_wait(B0, EMB_NOTIFY_ANY | EMB_NOTIFY_CLEAR, EMB_TIMEOUT(EMB_TICKS(5)), + &got) == EMB_OK + ? 1 + : 2); +} + +EMB_TEST(notify_bound_target_cannot_be_destroyed) +{ + emb_sem_t sem; + emb_thread_t h; + EMB_TEST_REQ("KRN-NOTIF-014", "KRN-NOTIF-009"); + EMB_ASSERT_OK(emb_sem_init(&sem_storage, NULL, &sem)); + h = emb_test_thread(EMB_TEST_NAME("w"), 3u, 0u, wait_b0, NULL); /* blocks on B0 */ + EMB_ASSERT_OK(emb_sem_bind_notify(sem, h, B0)); + EMB_ASSERT_OK(emb_sem_give(sem)); /* the binding wakes it, and it exits */ + EMB_ASSERT_MARKS(1); + EMB_ASSERT_OK(emb_thread_join(h, EMB_WAIT_FOREVER, NULL)); + EMB_ASSERT_MISUSE(emb_thread_destroy(h), EMB_EBUSY); /* the semaphore still names it */ + EMB_ASSERT_STATUS(emb_sem_bind_notify(sem, h, B0), EMB_EEXIST); + EMB_ASSERT_OK(emb_sem_destroy(sem)); /* destroying the object releases the target */ + EMB_ASSERT_OK(emb_test_thread_destroy(h)); +} + +EMB_TEST(notify_bind_refuses_a_terminated_target) +{ + emb_sem_t sem; + emb_thread_t h = emb_test_thread(EMB_TEST_NAME("x"), 3u, 0u, exits, NULL); + EMB_TEST_REQ("KRN-NOTIF-014"); + EMB_ASSERT_OK(emb_sem_init(&sem_storage, NULL, &sem)); + EMB_ASSERT_STATUS(emb_sem_bind_notify(sem, h, B0), EMB_ESTATE); + EMB_ASSERT_STATUS(emb_sem_unbind_notify(sem), EMB_ENOENT); + EMB_ASSERT_OK(emb_sem_destroy(sem)); +} #endif diff --git a/tests/differential/diff_runner.c b/tests/differential/diff_runner.c index 192186e..0960cb9 100644 --- a/tests/differential/diff_runner.c +++ b/tests/differential/diff_runner.c @@ -16,7 +16,7 @@ * Operations (t: -1 forever, 0 no wait, n ticks): * take s t | give s | lock m t | unlock m | nset tid bits | nwait mask all clear t | * sleep t | yield | setprio tid prio | suspend tid | resume tid | cancel tid | destroy s | - * slock | sunlock | exit code | start tid | join tid t + * slock | sunlock | exit code | start tid | join tid t | bind s tid bit | unbind s | tdestroy tid * A schedule entry fires right after the n-th trace event (or in idle, if that event * never comes): "isr@n" raises ISR i, "tick@n" advances virtual time by one tick. */ @@ -66,7 +66,10 @@ enum op_kind { OP_SUNLOCK, OP_EXIT, OP_START, - OP_JOIN + OP_JOIN, + OP_BIND, + OP_UNBIND, + OP_TDESTROY }; struct op { @@ -92,6 +95,7 @@ struct thread_desc { int status[MAX_OPS]; unsigned long data[MAX_OPS]; unsigned done; + bool destroyed; /* emb_thread_destroy succeeded: the handle is stale */ }; struct sem_desc { @@ -265,6 +269,17 @@ static void parse_op(char *text, struct op *op) op->kind = OP_JOIN; op->a = num(args[0]); op->b = num(args[1]); + } else if (strcmp(name, "bind") == 0) { + op->kind = OP_BIND; + op->a = num(args[0]); + op->b = num(args[1]); + op->c = num(args[2]); + } else if (strcmp(name, "unbind") == 0) { + op->kind = OP_UNBIND; + op->a = num(args[0]); + } else if (strcmp(name, "tdestroy") == 0) { + op->kind = OP_TDESTROY; + op->a = num(args[0]); } else { die("unknown operation"); } @@ -580,6 +595,21 @@ static void run_ops(const struct program *prog, struct thread_desc *self) data = (unsigned long)(long)code; break; } + case OP_BIND: + st = emb_sem_bind_notify(sem_by_sid(op->a), thread_by_tid(op->b)->handle, + EMB_NOTIFY_BIT((unsigned)op->c)); + break; + case OP_UNBIND: + st = emb_sem_unbind_notify(sem_by_sid(op->a)); + break; + case OP_TDESTROY: { + struct thread_desc *d = thread_by_tid(op->a); + st = emb_thread_destroy(d->handle); + if (st == EMB_OK) { + d->destroyed = true; + } + break; + } default: die("bad operation kind"); break; @@ -640,7 +670,7 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) (void)printf("res %ld %u %d %lu\n", threads[i].tid, k, (int)threads[i].status[k], threads[i].data[k]); } - if (!EMB_HANDLE_IS_NULL(threads[i].handle) && + if (!EMB_HANDLE_IS_NULL(threads[i].handle) && !threads[i].destroyed && emb_thread_state(threads[i].handle, &state, &reason) == EMB_OK) { (void)printf("state %ld %u %u\n", threads[i].tid, (unsigned)state, (unsigned)reason); } diff --git a/tools/fault/api-ids.json b/tools/fault/api-ids.json index 947507b..aa50e9c 100644 --- a/tools/fault/api-ids.json +++ b/tools/fault/api-ids.json @@ -499,6 +499,12 @@ "name": "emb_fault_hook", "header": "fault.h", "state": "active" + }, + { + "id": 84, + "name": "emb_sem_unbind_notify", + "header": "sem.h", + "state": "active" } ] } diff --git a/tools/fault/api-registry.json b/tools/fault/api-registry.json index 792048d..3d9e448 100644 --- a/tools/fault/api-registry.json +++ b/tools/fault/api-registry.json @@ -498,6 +498,12 @@ "name": "emb_fault_hook", "header": "fault.h", "state": "active" + }, + { + "id": 84, + "name": "emb_sem_unbind_notify", + "header": "sem.h", + "state": "active" } ] } diff --git a/tools/model/bridge.py b/tools/model/bridge.py index 568d17d..fd37e68 100644 --- a/tools/model/bridge.py +++ b/tools/model/bridge.py @@ -28,9 +28,10 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from embmodel import (Cancel, Destroy, Exit, Give, Join, Lock, ModelError, NotifySet, # noqa: E402 - NotifyWait, Policy, Reason, Result, Resume, Scenario, SchedLock, SchedUnlock, - SetPrio, Sleep, Start, Suspend, Take, Unlock, Yield, build, FOREVER, NO_WAIT) +from embmodel import (BindNotify, Cancel, Destroy, Exit, Give, Join, Lock, ModelError, # noqa: E402 + NONWAIT_RESULT_OPS, NotifySet, NotifyWait, Policy, Reason, Result, Resume, Scenario, + SchedLock, SchedUnlock, SetPrio, Sleep, Start, Suspend, Take, ThreadDestroy, + UnbindNotify, Unlock, Yield, build, FOREVER, NO_WAIT) from tests import scenarios as catalogue # noqa: E402 # ---- the kernel's trace vocabulary (include/emb/trace.h, M1 subset) ------------------------ @@ -48,6 +49,9 @@ STATUS_TO_RESULT = {0: Result.SATISFIED, -4: Result.TIMEOUT, -5: Result.CANCELED, -6: Result.DESTROYED, -16: Result.STALE, -32: Result.DEADLOCK, -1: Result.EPERM, -3: Result.EPERM, -2: Result.STALE} # EINVAL on a wait: the pointer handle of a destroyed object +# emb_status_t -> Result for the non-wait operations whose status is compared (M2-03 binding lifetime) +NONWAIT_STATUS_TO_RESULT = {0: Result.SATISFIED, -1: Result.EPERM, -2: Result.STALE, -3: Result.BUSY, + -12: Result.NOENT, -13: Result.EXIST, -14: Result.STATE} # EMB_WAIT_REASON_* (thread.h: NONE 0, START 1, SUSPEND 2, SLEEP 3, OBJECT 4, JOIN 5, NOTIFY 6) -> Reason KREASON_TO_REASON = {1: Reason.START, 2: Reason.SUSPEND, 3: Reason.SLEEP, 4: Reason.OBJECT, 5: Reason.JOIN, 6: Reason.NOTIFY} @@ -72,6 +76,14 @@ def fault_result(fault_class, code): return STATUS_TO_RESULT.get(-code) # EMB_FAULT_API_OWNER etc.: the status the call carries +def fault_results(fault_class, code) -> set: + """Every model result the fault may stand for, waits and non-wait operations alike.""" + out = {fault_result(fault_class, code)} + if fault_class != 3: + out.add(NONWAIT_STATUS_TO_RESULT.get(-code)) + return out - {None} + + # ---- scenario encoding for the runner ------------------------------------------------------- def _t(t): @@ -115,6 +127,12 @@ def encode_op(op) -> str: return f"start {op.tid}" if isinstance(op, Join): return f"join {op.tid} {_t(op.timeout)}" + if isinstance(op, BindNotify): + return f"bind {op.sem} {op.tid} {op.bit}" + if isinstance(op, UnbindNotify): + return f"unbind {op.sem}" + if isinstance(op, ThreadDestroy): + return f"tdestroy {op.tid}" raise ValueError(f"unknown op {op!r}") @@ -418,9 +436,9 @@ def replay(self): continue if nxt[0] == "fault": # a checked build faults on misuse where the model records the error result - want = fault_result(nxt[1], nxt[2]) + want = fault_results(nxt[1], nxt[2]) new = [res for tid, t in n.threads.items() for res in t.results[before[tid]:]] - if want is not None and any(res[1] == want for res in new): + if any(res[1] in want for res in new): candidates.append((n, pos + 1, qmap, mmap, path + (act, "fault"), last_ticks)) elif not model_canonical(k.current, n.trace, act): candidates.append((n, pos, qmap, mmap, path + (act,), last_ticks)) @@ -517,7 +535,8 @@ def finish(self, k, path): if op_index not in kres: raise Divergence(f"thread {tid} op {op_index} {op!r}: model {result.name}, kernel has no result") kstatus, kdata = kres[op_index] - kres_mapped = STATUS_TO_RESULT.get(kstatus) + kmap = NONWAIT_STATUS_TO_RESULT if isinstance(op, NONWAIT_RESULT_OPS) else STATUS_TO_RESULT + kres_mapped = kmap.get(kstatus) if isinstance(op, Sleep) and kstatus == 0: kres_mapped = Result.TIMEOUT # a completed sleep is EMB_OK in the kernel if kres_mapped != result: @@ -525,6 +544,10 @@ def finish(self, k, path): if isinstance(op, (NotifyWait, Join, Lock)) and result == Result.SATISFIED and kdata != data: raise Divergence(f"thread {tid} op {op_index} {op!r}: model data {data}, kernel data {kdata}") kstate = r.states.get(tid) + if t.destroyed: + if kstate is not None: + raise Divergence(f"thread {tid}: destroyed in the model, the kernel reports state {kstate}") + continue # no handle names it any more if kstate is None: raise Divergence(f"thread {tid}: no final state from the kernel") k_blocked = kstate[0] == STATE_BLOCKED and kstate[1] != REASON_SUSPEND_K diff --git a/tools/model/embmodel/__init__.py b/tools/model/embmodel/__init__.py index cda0430..2366040 100644 --- a/tools/model/embmodel/__init__.py +++ b/tools/model/embmodel/__init__.py @@ -9,7 +9,7 @@ granularity and checks the invariants of SPEC-004 §5.4 after every step. """ from .kernel import ( # noqa: F401 - Cancel, Destroy, Exit, Give, Join, Kernel, Lock, ModelError, Mutex, NotifySet, NotifyWait, Policy, + BindNotify, Cancel, Destroy, Exit, Give, Join, NONWAIT_RESULT_OPS, ThreadDestroy, UnbindNotify, Kernel, Lock, ModelError, Mutex, NotifySet, NotifyWait, Policy, Protocol, Reason, Result, Resume, Start, SchedLock, SchedUnlock, Scenario, SetPrio, Sleep, Suspend, Take, Thread, Unlock, WaitState, Yield, FOREVER, NO_WAIT, OWNERDEAD_FLAG, PI_MAX_DEPTH, build, ) diff --git a/tools/model/embmodel/kernel.py b/tools/model/embmodel/kernel.py index 2a45b5c..d633be6 100644 --- a/tools/model/embmodel/kernel.py +++ b/tools/model/embmodel/kernel.py @@ -28,6 +28,10 @@ class Result(IntEnum): STALE = 4 DEADLOCK = 10 # EMB_EDEADLK: self-relock of a non-recursive mutex, or a detected cycle EPERM = 11 # EMB_EPERM: unlock by a non-owner, ceiling violation (release-build outcome) + BUSY = 12 # EMB_EBUSY from a non-wait operation (destroying a bound or live thread) + NOENT = 13 # EMB_ENOENT: unbind with no binding + EXIST = 14 # EMB_EEXIST: a second binding + STATE = 15 # EMB_ESTATE: binding a terminated thread OWNERDEAD_FLAG = 2 # hand-off word flag: the previous owner died (EMB_EOWNERDEAD) @@ -171,6 +175,28 @@ class Join: timeout: Optional[int] = FOREVER +@dataclass(frozen=True) +class BindNotify: + """emb_sem_bind_notify(sem, tid, 1 << bit) from thread context (SPEC-006 §3, ADR-046).""" + sem: int + tid: int + bit: int + + +@dataclass(frozen=True) +class UnbindNotify: + """emb_sem_unbind_notify(sem): later ready transitions set no bit (KRN-NOTIF-013).""" + sem: int + + +@dataclass(frozen=True) +class ThreadDestroy: + """emb_thread_destroy(tid): INACTIVE, or TERMINATED and collected, and no binding names it.""" + tid: int + + +NONWAIT_RESULT_OPS = (BindNotify, UnbindNotify, ThreadDestroy) # record a result although they never wait + ISR_SAFE_OPS = (Give, Resume, NotifySet, Start) # SPEC-001 §5.2: signal-type operations are `thread isr` @@ -205,6 +231,8 @@ class Thread: inactive: bool = False # INACTIVE: initialized, not started (SPEC-008 §4) exit_code: Optional[int] = None # set at termination (SPEC-008 §5) joiner: Optional[int] = None # the one joiner's tid, while blocked (SPEC-008 §6) + collected: bool = False # the exit code was joined: destroy is allowed (SPEC-008 §8) + destroyed: bool = False # emb_thread_destroy succeeded; no handle names it any more def key(self): return (self.tid, self.prio, self.base_prio, int(self.wait_state), self.wait_gen, @@ -212,7 +240,8 @@ def key(self): None if self.wake_result is None else int(self.wake_result), self.wake_data, self.suspended, self.cancel_pending, self.terminated, self.op_index, self.pc, self.saved_gen, self.deadline, self.results, self.notify_bits, self.notify_mask, self.notify_all, - self.notify_clear, tuple(self.owned), self.inactive, self.exit_code, self.joiner) + self.notify_clear, tuple(self.owned), self.inactive, self.exit_code, self.joiner, + self.collected, self.destroyed) @dataclass @@ -709,6 +738,8 @@ def _notify_satisfied(t: Thread) -> bool: def _notify_set(self, t: Thread, bits: int): """emb_notify_set: OR the bits; hand over and wake the owner if its wait is now satisfied. A set to a terminated thread is dropped (SPEC-006 §3.2, SPEC-008 §5).""" + if t.destroyed: + raise ModelError(f"notification set to destroyed thread {t.tid}: a dangling binding (ADR-046)") if t.terminated: self._event("notify_dropped", t.tid, bits) return @@ -827,6 +858,7 @@ def _exit(self, t: Thread, code: int = 0): if w is not None: self.wake(w, Result.SATISFIED, code, jq, handoff=True) t.joiner = None + t.collected = True t.notify_bits = 0 t.cancel_pending = False t.terminated = True @@ -857,6 +889,7 @@ def satisfied(): return target.terminated def consume(): + target.collected = True return target.exit_code if target.exit_code is not None else 0 def on_enqueue(): @@ -919,6 +952,50 @@ def _op_destroy(self, op: Destroy): self.wake_all(self.queues[sem.qid], Result.DESTROYED) sem.destroyed = True sem.gen += 1 + sem.binding = None # releases the target (ADR-046) + + # ------------------------------------------------------------------ binding lifetime (M2-03) + def _record(self, t: Thread, result: Result): + """The status of a non-wait operation that the bridge compares.""" + t.results = t.results + ((t.op_index, result, 0),) + + def _bound_to(self, tid: int) -> bool: + return any(s.binding is not None and s.binding[0] == tid for s in self.sems.values()) + + def _op_bind(self, t: Thread, op: BindNotify) -> Result: + sem, target = self.sems[op.sem], self.threads[op.tid] + if sem.destroyed or target.destroyed: + return Result.STALE # a stale POINTER handle + if sem.binding is not None: + return Result.EXIST + if target.terminated: + return Result.STATE + sem.binding = (op.tid, op.bit) + return Result.SATISFIED + + def _op_unbind(self, op: UnbindNotify) -> Result: + sem = self.sems[op.sem] + if sem.destroyed: + return Result.STALE + if sem.binding is None: + return Result.NOENT + sem.binding = None + return Result.SATISFIED + + def _op_thread_destroy(self, op: ThreadDestroy) -> Result: + target = self.threads[op.tid] + if target.destroyed: + return Result.STALE + reusable = target.inactive or (target.terminated and target.collected) + if not reusable or self._bound_to(op.tid): + return Result.BUSY # misuse: a fault when checked + if target.inactive: # never started: leaves its START wait + q = self.queues[target.wait_queue] + q.waiters = [(tid, s) for tid, s in q.waiters if tid != target.tid] + target.wait_queue, target.wait_reason, target.wait_state = None, None, WaitState.READY + target.inactive, target.terminated = False, True + target.destroyed = True + return Result.SATISFIED def step_thread(self) -> bool: if self.current is None: @@ -972,6 +1049,15 @@ def step_thread(self) -> bool: self._op_destroy(op) self._advance(t) self.reschedule_if_needed() + elif isinstance(op, NONWAIT_RESULT_OPS): + if isinstance(op, BindNotify): + res = self._op_bind(t, op) + elif isinstance(op, UnbindNotify): + res = self._op_unbind(op) + else: + res = self._op_thread_destroy(op) + self._record(t, res) + self._advance(t) elif isinstance(op, SchedLock): self.sched_lock_depth += 1 self._advance(t) @@ -1058,6 +1144,11 @@ def check_invariants(self): jq = self.queues[-(1000 + t.tid)] if len(jq.waiters) > 1: raise ModelError(f"thread {t.tid} has {len(jq.waiters)} joiners (KRN-THR-018)") + if t.destroyed and not t.terminated: + raise ModelError(f"destroyed thread {t.tid} is not terminated") + for s in self.sems.values(): # ADR-046: no binding outlives its target + if s.binding is not None and self.threads[s.binding[0]].destroyed: + raise ModelError(f"semaphore {s.sid} is bound to destroyed thread {s.binding[0]}") if t.terminated and jq.waiters: raise ModelError(f"terminated thread {t.tid} still has a blocked joiner") if t.wait_state == WaitState.BLOCKED and t.wait_reason == Reason.NOTIFY and self._notify_satisfied(t): diff --git a/tools/model/evidence.py b/tools/model/evidence.py index 766c5c3..5c7a4a9 100644 --- a/tools/model/evidence.py +++ b/tools/model/evidence.py @@ -23,7 +23,8 @@ OPS = {op.__name__: op for op in (bridge.Take, bridge.Give, bridge.Lock, bridge.Unlock, bridge.NotifySet, bridge.NotifyWait, bridge.Sleep, bridge.Yield, bridge.SetPrio, bridge.Suspend, bridge.Resume, bridge.Cancel, bridge.Destroy, bridge.SchedLock, - bridge.SchedUnlock, bridge.Exit, bridge.Start, bridge.Join)} + bridge.SchedUnlock, bridge.Exit, bridge.Start, bridge.Join, + bridge.BindNotify, bridge.UnbindNotify, bridge.ThreadDestroy)} def scenario_data(sc): diff --git a/tools/model/tests/scenarios.py b/tools/model/tests/scenarios.py index 80a7816..79e7083 100644 --- a/tools/model/tests/scenarios.py +++ b/tools/model/tests/scenarios.py @@ -336,3 +336,78 @@ def exit_owning_mutex_joined(): JOIN = [join_before_exit, join_after_exit, join_timeout_then_exit, join_second_ebusy, cancel_joiner, start_inactive, exit_owning_mutex_joined] ALL = ALL + JOIN + + +# --------------------------------------------------------------------------------------- +# M2-03: binding lifetime (SPEC-006 §3, ADR-046) +# --------------------------------------------------------------------------------------- +from embmodel import BindNotify, ThreadDestroy, UnbindNotify # noqa: E402 + + +def unbind_then_give(): + """T1 binds S0 to T2 and unbinds before giving: T2's notification wait times out.""" + return Scenario(name="unbind_then_give", + threads={1: (3, (BindNotify(0, 2, 0), UnbindNotify(0), UnbindNotify(0), Sleep(1), Give(0))), + 2: (2, (NotifyWait(0x1, timeout=3),))}, + sems={0: dict(count=0)}) + + +def isr_give_vs_unbind(): + """An ISR gives S0 around T1's unbind: T2 gets the bit only if the give came first.""" + return Scenario(name="isr_give_vs_unbind", + threads={1: (3, (BindNotify(0, 2, 0), Sleep(1), UnbindNotify(0))), + 2: (2, (NotifyWait(0x1, timeout=3),))}, + sems={0: dict(count=0)}, + isrs=((Give(0),),)) + + +def destroy_target_while_bound(): + """T1 binds S0 to T2, joins it, and may destroy it only after unbinding.""" + return Scenario(name="destroy_target_while_bound", + threads={1: (3, (BindNotify(0, 2, 0), Join(2), ThreadDestroy(2), UnbindNotify(0), + ThreadDestroy(2))), + 2: (2, (Sleep(1), Exit(4)))}, + sems={0: dict(count=0)}) + + +def destroy_bound_sem_releases_target(): + """Destroying the bound semaphore releases the target; a waiter leaves with DESTROYED.""" + return Scenario(name="destroy_bound_sem_releases_target", + threads={1: (1, (BindNotify(0, 2, 0), Join(2), Destroy(0), ThreadDestroy(2))), + 2: (2, (Exit(1),)), + 3: (3, (Take(0, 2),))}, + sems={0: dict(count=0)}) + + +def bind_terminated_target(): + """Binding a terminated thread is refused; an INACTIVE one can be bound and then destroyed after unbind.""" + return Scenario(name="bind_terminated_target", + threads={1: (1, (Join(2), BindNotify(0, 2, 0), BindNotify(0, 3, 0), ThreadDestroy(3), + UnbindNotify(0), ThreadDestroy(3))), + 2: (2, (Exit(0),)), + 3: (3, (Exit(0),))}, + sems={0: dict(count=0)}, + inactive=(3,)) + + +def suspended_target_accumulates(): + """A bound target suspended while waiting keeps the handed-off bit and returns it at resume.""" + return Scenario(name="suspended_target_accumulates", + threads={1: (1, (BindNotify(0, 2, 0), Suspend(2), Give(0), Resume(2))), + 2: (2, (NotifyWait(0x1),))}, + sems={0: dict(count=0)}) + + +def rebind_to_another_thread(): + """A second bind is refused until unbind; the rebound thread receives the next ready transition.""" + return Scenario(name="rebind_to_another_thread", + threads={1: (4, (BindNotify(0, 2, 0), BindNotify(0, 3, 1), UnbindNotify(0), + BindNotify(0, 3, 1), Sleep(1), Give(0))), + 2: (2, (NotifyWait(0x1, timeout=3),)), + 3: (3, (NotifyWait(0x2, timeout=3),))}, + sems={0: dict(count=0)}) + + +BINDING = [unbind_then_give, isr_give_vs_unbind, destroy_target_while_bound, destroy_bound_sem_releases_target, + bind_terminated_target, suspended_target_accumulates, rebind_to_another_thread] +ALL = ALL + BINDING diff --git a/tools/model/tests/test_explore.py b/tools/model/tests/test_explore.py index 2a9232c..4dd5168 100644 --- a/tools/model/tests/test_explore.py +++ b/tools/model/tests/test_explore.py @@ -216,3 +216,59 @@ def test_canceled_joiner_frees_the_slot(self): d = dict(dict(outcome)["threads"]) self.assertEqual(d[1][0][1], Result.CANCELED) self.assertIn(d[1][1][1], (Result.TIMEOUT, Result.SATISFIED)) # NO_WAIT join: T2 may or may not have exited + + +class BindingLifetime(unittest.TestCase): + """M2-03 (ADR-046): no binding outlives its target; unbind stops signals deterministically.""" + + @staticmethod + def results(make): + rep = explore(make()) + return rep, [dict(dict(o)["threads"]) for o in rep.outcomes] + + def test_every_binding_scenario_holds_invariants(self): + for make in S.BINDING: + sc = make() + with self.subTest(scenario=sc.name): + self.assertGreater(explore(sc).terminals, 0) + + def test_unbind_before_give_never_signals(self): + _, outs = self.results(S.unbind_then_give) + for d in outs: + self.assertEqual([r[1] for r in d[1] if r[0] < 3], [Result.SATISFIED, Result.SATISFIED, Result.NOENT]) + self.assertEqual(d[2][0][1], Result.TIMEOUT) + + def test_isr_give_races_unbind_both_ways(self): + _, outs = self.results(S.isr_give_vs_unbind) + self.assertEqual({d[2][0][1] for d in outs}, {Result.SATISFIED, Result.TIMEOUT}) + + def test_bound_target_is_destroyed_only_after_unbind(self): + _, outs = self.results(S.destroy_target_while_bound) + for d in outs: + self.assertEqual([r[1] for r in d[1]], + [Result.SATISFIED, Result.SATISFIED, Result.BUSY, Result.SATISFIED, Result.SATISFIED]) + + def test_destroying_the_object_releases_the_target(self): + _, outs = self.results(S.destroy_bound_sem_releases_target) + for d in outs: + self.assertEqual(d[1][-1][1], Result.SATISFIED) + self.assertIn(d[3][0][1], (Result.DESTROYED, Result.TIMEOUT)) + + def test_terminated_target_refused_inactive_target_released_by_unbind(self): + _, outs = self.results(S.bind_terminated_target) + for d in outs: + self.assertEqual([r[1] for r in d[1]][1:], + [Result.STATE, Result.SATISFIED, Result.BUSY, Result.SATISFIED, Result.SATISFIED]) + + def test_suspended_target_receives_the_bit_at_resume(self): + _, outs = self.results(S.suspended_target_accumulates) + for d in outs: + self.assertEqual(d[2][0][1], Result.SATISFIED) + + def test_rebind_signals_the_new_target_only(self): + _, outs = self.results(S.rebind_to_another_thread) + for d in outs: + self.assertEqual([r[1] for r in d[1] if r[0] < 4], + [Result.SATISFIED, Result.EXIST, Result.SATISFIED, Result.SATISFIED]) + self.assertEqual(d[2][0][1], Result.TIMEOUT) # the old target is never signaled + self.assertIn(Result.SATISFIED, {d[3][0][1] for d in outs}) diff --git a/tools/toolchain/native_abi.c b/tools/toolchain/native_abi.c index 9e125f2..f96aed3 100644 --- a/tools/toolchain/native_abi.c +++ b/tools/toolchain/native_abi.c @@ -32,7 +32,9 @@ void emb_native_abi_probe(void) #if !EMBK_THREAD_INDEX_PACKED FIELD(thread_index, embk_thread_t, index); #endif +#if !CONFIG_EMB_SCHED_TABLE FIELD(thread_wait_gen, embk_thread_t, wait_gen); +#endif FIELD(thread_wake_data, embk_thread_t, wake_data); FIELD(thread_wait_queue, embk_thread_t, wait_queue); FIELD(thread_timeout, embk_thread_t, timeout); @@ -53,6 +55,7 @@ void emb_native_abi_probe(void) TYPE(thread_notify, embk_thread_t); FIELD(thread_notify_notify_bits, embk_thread_t, notify_bits); FIELD(thread_notify_notify_mask, embk_thread_t, notify_mask); + FIELD(thread_notify_bind_refs, embk_thread_t, bind_refs); #endif #if CONFIG_EMB_MUTEX TYPE(thread_mutex, embk_thread_t); @@ -119,8 +122,10 @@ void emb_native_abi_probe(void) #endif #if CONFIG_EMB_SEM && CONFIG_EMB_NOTIFY TYPE(sem_notify, embk_sem_t); - FIELD(sem_notify_bind_thread, embk_sem_t, bind_thread); - FIELD(sem_notify_bind_bit, embk_sem_t, bind_bit); + FIELD(sem_notify_binding, embk_sem_t, binding); + TYPE(binding, embk_binding_t); + FIELD(binding_thread, embk_binding_t, thread); + FIELD(binding_bit, embk_binding_t, bit); #endif #if CONFIG_EMB_MUTEX TYPE(mutex, embk_mutex_t); From b823fbbe64da400a3f5e32fb5a6b792c2b3bbc62 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 18:45:41 +0100 Subject: [PATCH 11/30] feat(evidence): report and collect individual conformance cases (M2-04) Requirement citations in tests were thrown away at run time, and CTest saw one result per executable, so no tool could say which requirement an executed, asserting case had checked in which configuration. The framework now prints three new line types. The existing lines are unchanged, so the QEMU and EmbSIM runners still parse their summary. - EVIDENCE: once per run, with the suite (generated per executable by emb_add_test), the configuration hash, profile, board and checked mode. - req: the case's citations, buffered and printed after the body. Output inside a case cost the EmbSIM ports ticks and broke an elapsed-time check. - CASE: the outcome (passed, failed, skipped) with assertion and skip counts. Every assertion macro now counts once. tools/evidence/collect.py reads CTest's LastTest.log from any port and writes the cases with the source identity. Every CI test job collects and uploads it, even when tests fail. tools/evidence/matrix.py derives the requirement-by-configuration matrix with design status, evidence outcome, execution scope and coverage type in separate columns. Only citations printed by executed, asserting cases count. Configurations without evidence, among them the not-yet-possible NUCLEO-F446RE HIL run, appear as not run with their reason. The first matrix finds 14 cited identifiers (KRN-SCH-*, KRN-MEM-009) in groups whose migration is still pending and whose v0.1 text is not in the repository; they stay visible as uncataloged rows. All 18 local targets pass (native, QEMU Cortex-M/RV32/AVR, EmbSIM Uno, NUCLEO-F446RE and ESP32-C3), and the AVR test images still fit. Signed-off-by: teo1747 --- .github/workflows/ci.yml | 72 +++++++++++++ CHANGELOG.md | 2 + cmake/emb_target.cmake | 6 +- docs/STATUS.md | 2 +- docs/engineering/case-evidence.md | 60 +++++++++++ docs/engineering/index.md | 1 + docs/engineering/m2-checklist.md | 6 +- docs/requirements/README.md | 4 +- tests/framework/emb_test.c | 68 +++++++++++- tests/framework/emb_test.h | 13 ++- tools/evidence/collect.py | 147 ++++++++++++++++++++++++++ tools/evidence/configurations.json | 25 +++++ tools/evidence/matrix.py | 137 ++++++++++++++++++++++++ tools/evidence/test_evidence_tools.py | 94 ++++++++++++++++ 14 files changed, 627 insertions(+), 10 deletions(-) create mode 100644 docs/engineering/case-evidence.md create mode 100644 tools/evidence/collect.py create mode 100644 tools/evidence/configurations.json create mode 100644 tools/evidence/matrix.py create mode 100644 tools/evidence/test_evidence_tools.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 027b1b5..4650df1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,16 @@ jobs: run: cmake --build --preset ${{ matrix.preset }} - name: Test run: ctest --preset ${{ matrix.preset }} --output-on-failure --no-tests=error + - name: Collect case evidence + if: always() + run: python3 -I tools/evidence/collect.py --build build/${{ matrix.preset }} --preset ${{ matrix.preset }} --out case-evidence-${{ matrix.preset }}.json + - name: Upload case evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: case-evidence-${{ matrix.preset }} + path: case-evidence-${{ matrix.preset }}.json + if-no-files-found: warn - name: Numeric fault IDs without location strings if: matrix.preset == 'native-gcc' run: | @@ -73,6 +83,16 @@ jobs: run: cmake --build --preset ${{ matrix.preset }} - name: Test under QEMU run: ctest --preset ${{ matrix.preset }} --output-on-failure --no-tests=error --verbose + - name: Collect case evidence + if: always() + run: python3 -I tools/evidence/collect.py --build build/${{ matrix.preset }} --preset ${{ matrix.preset }} --out case-evidence-${{ matrix.preset }}.json + - name: Upload case evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: case-evidence-${{ matrix.preset }} + path: case-evidence-${{ matrix.preset }}.json + if-no-files-found: warn - name: Single-level non-nesting IRQ conformance if: matrix.preset == 'cm3-an385' run: | @@ -111,6 +131,16 @@ jobs: run: cmake --build --preset ${{ matrix.preset }} - name: Test under QEMU run: ctest --preset ${{ matrix.preset }} --output-on-failure --no-tests=error + - name: Collect case evidence + if: always() + run: python3 -I tools/evidence/collect.py --build build/${{ matrix.preset }} --preset ${{ matrix.preset }} --out case-evidence-${{ matrix.preset }}.json + - name: Upload case evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: case-evidence-${{ matrix.preset }} + path: case-evidence-${{ matrix.preset }}.json + if-no-files-found: warn - name: Footprint report run: | python3 -I tools/footprint/footprint.py \ @@ -147,6 +177,16 @@ jobs: run: cmake --build --preset ${{ matrix.preset }} - name: Test under QEMU run: ctest --preset ${{ matrix.preset }} --output-on-failure --no-tests=error + - name: Collect case evidence + if: always() + run: python3 -I tools/evidence/collect.py --build build/${{ matrix.preset }} --preset ${{ matrix.preset }} --out case-evidence-${{ matrix.preset }}.json + - name: Upload case evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: case-evidence-${{ matrix.preset }} + path: case-evidence-${{ matrix.preset }}.json + if-no-files-found: warn - name: Footprint run: | python3 -I tools/footprint/footprint.py \ @@ -186,6 +226,8 @@ jobs: run: python3 -I -m unittest discover -s tools/esp -p 'test_*.py' -v - name: Footprint report tool run: python3 -I -m unittest discover -s tools/footprint -p 'test_*.py' -v + - name: Case evidence tools + run: python3 -I -m unittest discover -s tools/evidence -p 'test_*.py' -v - name: Configuration tool self-check run: | python3 -I tools/kconfig/embconfig.py --kconfig Kconfig --manifest arch/native/arch.yaml \ @@ -226,6 +268,16 @@ jobs: run: cmake --build --preset avr-uno-embsim - name: Test on EmbSIM run: ctest --preset avr-uno-embsim --output-on-failure --no-tests=error + - name: Collect case evidence + if: always() + run: python3 -I tools/evidence/collect.py --build build/avr-uno-embsim --preset avr-uno-embsim --out case-evidence-avr-uno-embsim.json + - name: Upload case evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: case-evidence-avr-uno-embsim + path: case-evidence-avr-uno-embsim.json + if-no-files-found: warn cm4-embsim: # the conformance suite on EmbSIM's NUCLEO-F446RE (SIM-002), built as for the board @@ -255,6 +307,16 @@ jobs: run: cmake --build --preset cm4-nucleo-embsim - name: Test on EmbSIM run: ctest --preset cm4-nucleo-embsim --output-on-failure --no-tests=error --verbose + - name: Collect case evidence + if: always() + run: python3 -I tools/evidence/collect.py --build build/cm4-nucleo-embsim --preset cm4-nucleo-embsim --out case-evidence-cm4-nucleo-embsim.json + - name: Upload case evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: case-evidence-cm4-nucleo-embsim + path: case-evidence-cm4-nucleo-embsim.json + if-no-files-found: warn rv32-embsim: # the conformance suite on EmbSIM's ESP32-C3-DevKitM-1 (SIM-002): each image turned into the @@ -285,6 +347,16 @@ jobs: run: cmake --build --preset rv32-c3-embsim - name: Test on EmbSIM run: ctest --preset rv32-c3-embsim --output-on-failure --no-tests=error + - name: Collect case evidence + if: always() + run: python3 -I tools/evidence/collect.py --build build/rv32-c3-embsim --preset rv32-c3-embsim --out case-evidence-rv32-c3-embsim.json + - name: Upload case evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: case-evidence-rv32-c3-embsim + path: case-evidence-rv32-c3-embsim.json + if-no-files-found: warn merge_gate: name: CI merge gate diff --git a/CHANGELOG.md b/CHANGELOG.md index c3cb355..3ab1936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Added +- Executed case evidence (M2-04): conformance runs print each case's requirement citations and outcome with assertion and skip counts; `tools/evidence/collect.py` gathers them from the CTest log of any port, CI uploads them per configuration, and `tools/evidence/matrix.py` derives the requirement-by-configuration matrix from executed, asserting cases only. + - `emb_sem_unbind_notify()` (KRN-NOTIF-013). A thread counts the bindings that name it, and `emb_thread_destroy()` refuses it while any does; `emb_sem_bind_notify()` refuses terminated (`EMB_ESTATE`) and kernel (`EMB_EPERM`) targets (KRN-NOTIF-014, ADR-046). The reference model and the differential bridge cover bind, unbind and thread destroy. - `CONFIG_EMB_OBJ_LIST`: per-type lists of active semaphores and mutexes (KRN-OBJ-011). diff --git a/cmake/emb_target.cmake b/cmake/emb_target.cmake index 78613c0..c4dc4fc 100644 --- a/cmake/emb_target.cmake +++ b/cmake/emb_target.cmake @@ -38,7 +38,11 @@ endfunction() # directly on the native port (SPEC-013 §11), under the board's QEMU machine or on its # EmbSIM board elsewhere. function(emb_add_test name) - emb_add_executable(${name} ${ARGN}) + # the suite's name for the framework's evidence line (docs/engineering/case-evidence.md) + set(_suite "${CMAKE_CURRENT_BINARY_DIR}/${name}_suite.c") + file(GENERATE OUTPUT "${_suite}" + CONTENT "/* Generated by emb_add_test(). */\n#include \n\nEMB_FLASH_CONST char emb_test_suite[] = \"${name}\";\n") + emb_add_executable(${name} ${ARGN} "${_suite}") target_link_libraries(${name} PRIVATE emb_test) if(NOT CONFIG_EMB_ARCH STREQUAL "native") # the image runs on the board's emulator; the runner reads the console (SIM-002) diff --git a/docs/STATUS.md b/docs/STATUS.md index 2037dd8..4b6e714 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -23,7 +23,7 @@ This page is the authoritative inventory of implementation and available evidenc ## Specified but unimplemented -Event flags, condition variables, barriers, software timers, work queues, message queues, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; executed-case requirement traceability/coverage reports and support matrix; fuzz/HIL campaigns; SMP/AMP ports and transports. +Event flags, condition variables, barriers, software timers, work queues, message queues, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. An API or path named in a design or a requirement's verification field may be a planned deliverable. Check this inventory and the filesystem before treating it as available. diff --git a/docs/engineering/case-evidence.md b/docs/engineering/case-evidence.md new file mode 100644 index 0000000..4836608 --- /dev/null +++ b/docs/engineering/case-evidence.md @@ -0,0 +1,60 @@ +# M2-04: Executed case evidence + +This page defines how conformance runs report individual cases, how those reports are collected, and how the requirement matrix is derived from them. It implements the first and second M2-04 items of the [checklist](m2-checklist.md) under the rules of [timing and evidence](timing-and-evidence.md): design status, evidence outcome, execution scope and coverage type stay four separate facts. The resource-bound inventory is a separate item. + +## What a run prints + +The test framework (`tests/framework/emb_test.c`) adds three line types to the existing `TEST`, `-> ok`/`-> FAILED`, `skip:` and summary lines, all unchanged so that the QEMU and EmbSIM runners keep parsing `N tests, M failed`: + +```text +EVIDENCE v1 suite=test_sem config=9212dd95ff5d9762 profile=base board=native checked=1 +TEST sem_counts_and_nowait + -> ok + req KRN-SYNC-020 KRN-SYNC-021 +CASE sem_counts_and_nowait outcome=passed asserts=8 skips=0 +``` + +- `EVIDENCE` opens every run: the executable (generated per test by `emb_add_test()`), the configuration hash of ``, profile, board and checked mode. +- `req` lists the case's `EMB_TEST_REQ` citations. They are buffered and printed after the case body, because output inside a case takes ticks on a real-time port. Up to eight are kept; more are reported as `+N` and not recorded. +- `CASE` closes every case. Every assertion macro counts once, passing or not (`EMB_ASSERT_*`, marks and elapsed checks, and fork-based fault checks where the port can fork). The outcome is `failed` when any assertion failed, `skipped` when the case asserted nothing and skipped at least once (typically a misuse case whose only check needs `fork`), and `passed` otherwise. + +A case that prints `TEST` without its `CASE` line crashed or timed out inside the body; the collector reports it as `incomplete`. A case compiled out by its feature gate prints nothing in that configuration. + +## Collection + +`tools/evidence/collect.py --build build/ --preset --out ` reads the build's `Testing/Temporary/LastTest.log`, which CTest writes with every executable's full output whether it ran natively, under QEMU or on EmbSIM. Nothing runs twice. The JSON record (`emblink-case-evidence`, version 1) holds the source identity (`git rev-parse HEAD` and whether tracked files differ), the configuration identity the executables reported (the collector fails when they disagree), and per executable its CTest result and cases with outcome, assertion and skip counts, skip reasons and citations. Every CI test job collects after its CTest run, also when tests fail, and uploads `case-evidence-.json`. + +## The requirement matrix + +`tools/evidence/matrix.py [--csv FILE] [--json FILE]` joins the requirement catalog of `docs/requirements/` (through `tools/docs/build.py`) with the evidence files and the configuration inventory `tools/evidence/configurations.json`. Each row is one requirement in one configuration: + +| Column | Meaning | +|---|---| +| `design_status` | the catalog's Status: Proposed, Accepted, Implemented, Verified, Withdrawn | +| `evidence_outcome` | `passed`: an executed case citing the ID passed with at least one assertion; `failed`: a citing case failed or is incomplete; `skipped`: citing cases only skipped; `no assertion`: they ran but checked nothing; `no case`: nothing cites it here; `not run`: no evidence file for the configuration | +| `execution_scope` | `host`, `emulated` or `hardware` from the inventory, or `unrun:` and the stated reason | +| `coverage_type` | `conformance` for case evidence; model, analysis and coverage rows join as those tools report | +| `cases` | the citing cases as `executable:case` | + +Only a citation printed by an executed case counts. `@req` header tags, Verification fields and identifiers that merely appear in test sources are traceability links, not evidence; a requirement is never "passed" by its declaration. The inventory lists every preset with its scope and names the `nucleo-f446re-hil` configuration as unrun: there is no board and no HIL runner yet (M3a). + +Applicability follows the feature gates. A case exists in a configuration exactly when its `#if` gate is true there, so a `no case` row in a configuration that enables the feature is a gap. A `no case` row where the feature is off is out of scope for that configuration. The matrix does not yet tell the two apart automatically. + +## First findings + +Local runs of this change, on macOS with the toolchains of the earlier M2 reports: + +| Configuration | Cases | Requirements passed / skipped / no case | +|---|---|---| +| native-gcc | 97 passed | 94 / 0 / 283 | +| native-release | 90 passed | 94 / 0 / 283 | +| native-tiny | 92 passed | 90 / 0 / 287 | +| cm4-an386 (QEMU) | 85 passed, 10 skipped | 94 / 2 / 281 | +| cm4-nucleo-embsim | 85 passed, 10 skipped | 94 / 2 / 281 | +| avr-uno (QEMU) | 73 passed, 9 skipped | 87 / 1 / 289 | + +Most catalog requirements have no executed case: they describe planned features (event flags, queues, timers, isolation, hardware generation) or are verified by analysis that is not yet reported. Fourteen cited identifiers, `KRN-SCH-001` to `KRN-SCH-043` (thirteen) and `KRN-MEM-009`, belong to groups whose migration into `docs/requirements/` is still pending; their v0.1 statements are not in the repository, so the matrix keeps their evidence in rows marked `uncataloged (migration pending)` instead of inventing catalog text. A citation outside every known group fails the matrix. + +## Limits + +The case report proves that a case ran and what it asserted, not that its assertions express the requirement fully: reviewing the cited cases against the requirement text stays a human step. The outcome counts assertions, not branches; statement and branch coverage are M2-10. Evidence records the source identity at collection time; a build from a dirty tree is marked as such, not excluded. diff --git a/docs/engineering/index.md b/docs/engineering/index.md index e9f5349..22ba915 100644 --- a/docs/engineering/index.md +++ b/docs/engineering/index.md @@ -18,6 +18,7 @@ m2-02-irq-priority m2-02-thread-identity m2-02-fault-api-ids m2-02-object-lifecycle +case-evidence embcc-capability-review embcc-layout-marker embcc-native-linux diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index e2a6a1c..41a0902 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -58,10 +58,10 @@ The current docs disclose these gaps. Correcting one must update its contract, r ## M2-04: Executed cases and bounded costs -- [ ] Export individual conformance outcomes with requirement IDs, assertions/analyses, configuration hash, source SHA, skips and unrun reasons. -- [ ] Validate declared mappings against actual executed cases rather than counting header/test ID strings. Migrate legacy requirement definitions deliberately when their owning subsystem needs them. +- [x] Export individual conformance outcomes with requirement IDs, assertions/analyses, configuration hash, source SHA, skips and unrun reasons. The framework prints `EVIDENCE`, `req` and `CASE` lines; `tools/evidence/collect.py` records them with the source identity, and every CI test job uploads them (see [case evidence](case-evidence.md)). +- [x] Validate declared mappings against actual executed cases rather than counting header/test ID strings. Migrate legacy requirement definitions deliberately when their owning subsystem needs them. `tools/evidence/matrix.py` counts only citations printed by executed, asserting cases. The fourteen cited `KRN-SCH`/`KRN-MEM` identifiers of groups pending migration stay visible as uncataloged rows; their v0.1 text is not in the repository, so their migration waits for the scheduler and memory requirement work rather than being reconstructed. - [ ] Inventory admitted waiters, armed timeouts, owned mutexes, inheritance depth, tiny table scans and callbacks/batch wakes. -- [ ] Define feature/configuration applicability for each case and analysis. Accepted design status remains separate from evidence outcome. +- [x] Define feature/configuration applicability for each case and analysis. Accepted design status remains separate from evidence outcome. A case applies where its feature gate compiles it; `tools/evidence/configurations.json` gives every configuration its execution scope or unrun reason, and the matrix keeps design status and evidence outcome in separate columns. Telling an out-of-scope `no case` from a gap automatically is not yet done. The [timing/evidence contract](timing-and-evidence.md) controls these artifacts. Physical timing and full release reproducibility remain the subsequent hardware/release gates, not inferred results of this export. diff --git a/docs/requirements/README.md b/docs/requirements/README.md index cad3966..b0aaac9 100644 --- a/docs/requirements/README.md +++ b/docs/requirements/README.md @@ -4,7 +4,7 @@ This directory holds the normative requirements of EmbLinkRTOS, one file per ide ## Format -Every requirement is one block with a fixed shape so `tools/docs/build.py` can generate the design catalog and exports. The executed-case/profile traceability matrix remains a separate planned tool; source extraction alone does not produce it. +Every requirement is one block with a fixed shape so `tools/docs/build.py` can generate the design catalog and exports. The executed-case/profile traceability matrix is `tools/evidence/matrix.py`, built from executed case reports ([case evidence](../engineering/case-evidence.md)); source extraction alone does not produce it. ```markdown ### GROUP-NNN Short title @@ -15,7 +15,7 @@ Every requirement is one block with a fixed shape so `tools/docs/build.py` can g **Trace.** Design and decision references (SPEC-001 §4, ADR-002), and parent requirements if any. ``` -The verification fields below mix existing cases, analysis obligations and planned paths. They are not execution results. [Project status](../STATUS.md) inventories current tools; the Sphinx Needs design catalog is implemented, while the executed-evidence matrix is unimplemented. [Timing and evidence](../engineering/timing-and-evidence.md) defines its required profile/configuration scope and outcome fields. A test identifier mention alone does not establish coverage. New safety/security constraints from the documentation review are Proposed and unimplemented until their evidence exists. +The verification fields below mix existing cases, analysis obligations and planned paths. They are not execution results. [Project status](../STATUS.md) inventories current tools; the Sphinx Needs design catalog and the executed-evidence matrix are implemented. [Timing and evidence](../engineering/timing-and-evidence.md) defines its required profile/configuration scope and outcome fields. A test identifier mention alone does not establish coverage. New safety/security constraints from the documentation review are Proposed and unimplemented until their evidence exists. Rules: diff --git a/tests/framework/emb_test.c b/tests/framework/emb_test.c index 7dc23b8..a1584ce 100644 --- a/tests/framework/emb_test.c +++ b/tests/framework/emb_test.c @@ -1,6 +1,8 @@ /* SPDX-License-Identifier: Apache-2.0 */ /* Copyright 2026 Junior Deogracias */ /* Test runner: main() starts the kernel, a runner thread executes every EMB_TEST. */ +#include + #include #include @@ -9,6 +11,14 @@ EMB_TABLE_BOUNDS(const emb_test_case_t, emb_test_table); static unsigned failures; static unsigned skips; static unsigned current_failed; +static unsigned current_asserts; +static unsigned current_skips; + +/* The case's requirement citations, printed after it ends: output inside a case would + * take ticks on a real-time port. The pointers are string literals. */ +#define REQS_MAX 8u +static const char *current_reqs[REQS_MAX]; +static unsigned current_reqs_n; void emb_test_fail(unsigned line, const char *what_flash) { @@ -24,17 +34,52 @@ void emb_test_skip(const char *why_flash) emb_test_puts_flash(why_flash); emb_test_puts_flash(EMB_TEST_STR("\n")); skips++; + current_skips++; +} + +void emb_test_asserted(void) +{ + current_asserts++; } void emb_test_note_reqs(const char *first, ...) { - (void)first; /* collected statically by the traceability tool */ + va_list ap; + const char *id = first; + va_start(ap, first); + /* bounded by the citation list, which ends with a null pointer */ + while (id != NULL) { + if (current_reqs_n < REQS_MAX) { + current_reqs[current_reqs_n] = id; + } + current_reqs_n++; /* counts past the buffer, so an overflow is reported */ + id = va_arg(ap, const char *); + } + va_end(ap); +} + +static void print_reqs(void) +{ + unsigned i; + if (current_reqs_n == 0u) { + return; + } + emb_test_puts_flash(EMB_TEST_STR(" req")); + for (i = 0u; i < current_reqs_n && i < REQS_MAX; i++) { + emb_test_puts_flash(EMB_TEST_STR(" ")); + emb_test_puts(current_reqs[i]); + } + if (current_reqs_n > REQS_MAX) { + emb_test_logf(EMB_TEST_STR(" +%u"), current_reqs_n - REQS_MAX); /* not recorded */ + } + emb_test_puts_flash(EMB_TEST_STR("\n")); } void emb_test_check_elapsed(unsigned line, uint32_t elapsed, uint32_t expected) { /* exact in virtual time; a tick may pass during the test's own work in real time */ uint32_t slack = EMB_TEST_EXACT_TIME ? 0u : 1u; + emb_test_asserted(); if (elapsed < expected || elapsed > expected + slack) { emb_test_logf(EMB_TEST_STR(" elapsed %lu, expected %lu"), (unsigned long)elapsed, (unsigned long)expected); @@ -79,6 +124,7 @@ void emb_test_marks_check(unsigned line, const int *expected, size_t n) { size_t i; bool ok = marks_n == n; + emb_test_asserted(); for (i = 0u; ok && i < n; i++) { ok = marks[i] == expected[i]; } @@ -259,10 +305,19 @@ static void runner(void *arg) unsigned ran = 0u; (void)arg; emb_test_platform_irq_bind(dispatch); + /* one identity line per run (docs/engineering/case-evidence.md) */ + emb_test_puts_flash(EMB_TEST_STR("EVIDENCE v1 suite=")); + emb_test_puts_flash(emb_test_suite); + emb_test_logf(EMB_TEST_STR(" config=" EMB_CONFIG_HASH_STRING " profile=" CONFIG_EMB_PROFILE + " board=" EMB_HW_BOARD_NAME " checked=%u\n"), + (unsigned)CONFIG_EMB_CHECKED); for (; tc != NULL && tc < stop; tc++) { emb_test_fn_t fn = (emb_test_fn_t)EMB_FLASH_READ_FNPTR(&tc->fn); const char *name = (const char *)EMB_FLASH_READ_PTR(&tc->name); current_failed = 0u; + current_asserts = 0u; + current_skips = 0u; + current_reqs_n = 0u; emb_test_marks_clear(); emb_test_puts_flash(EMB_TEST_STR("TEST ")); emb_test_puts_flash(name); @@ -276,6 +331,17 @@ static void runner(void *arg) } else { emb_test_puts_flash(EMB_TEST_STR(" -> ok\n")); } + print_reqs(); + emb_test_puts_flash(EMB_TEST_STR("CASE ")); + emb_test_puts_flash(name); + if (current_failed != 0u) { + emb_test_puts_flash(EMB_TEST_STR(" outcome=failed")); + } else if (current_asserts == 0u && current_skips != 0u) { + emb_test_puts_flash(EMB_TEST_STR(" outcome=skipped")); + } else { + emb_test_puts_flash(EMB_TEST_STR(" outcome=passed")); + } + emb_test_logf(EMB_TEST_STR(" asserts=%u skips=%u\n"), current_asserts, current_skips); } emb_test_logf(EMB_TEST_STR("%u tests, %u failed, %u skipped, %lu ticks\n"), ran, failures, skips, (unsigned long)EMB_TEST_NOW()); diff --git a/tests/framework/emb_test.h b/tests/framework/emb_test.h index 9f8cc6e..90a272f 100644 --- a/tests/framework/emb_test.h +++ b/tests/framework/emb_test.h @@ -6,7 +6,8 @@ * function creates the threads it needs at higher priorities and synchronizes with * kernel primitives only (CS-14.2). The same sources run on every port: the platform * layer (emb_test_platform.h) supplies output, the software interrupt, and the exit. - * Requirement citations (EMB_TEST_REQ) are collected by the traceability generator. + * Requirement citations (EMB_TEST_REQ) are printed with each case, and every case ends + * with a machine-readable outcome line (M2-04, docs/engineering/case-evidence.md). */ #ifndef EMB_TEST_H #define EMB_TEST_H @@ -37,17 +38,22 @@ typedef struct EMB_ALIGNED(sizeof(void *) * 4) emb_test_case { EMB_USED EMB_SECTION(EMB_TABLE_SECTION("emb_test_table")) = {name##_name_, name, ""}; \ static void name(void) -/* Requirement citation inside a test body: collected by the traceability tool. */ +/* Requirement citation inside a test body: printed as the case's "req" line. */ #define EMB_TEST_REQ(...) emb_test_note_reqs(__VA_ARGS__, (const char *)0) void emb_test_note_reqs(const char *first, ...); void emb_test_fail(unsigned line, const char *what_flash); void emb_test_skip(const char *why_flash); +void emb_test_asserted(void); /* every assertion counts once, whether it passes or not */ + +/* The suite's name, defined for each test executable by emb_add_test(). */ +extern EMB_FLASH_CONST char emb_test_suite[]; /* Strings the framework prints: in flash on Harvard ports (EMB_TEST_STR), so the * assertion texts do not consume the 2 KB of SRAM of the ATmega328P. */ #define EMB_ASSERT_TRUE(cond) \ do { \ + emb_test_asserted(); \ if (!(cond)) { \ emb_test_fail(__LINE__, EMB_TEST_STR("expected: " #cond)); \ } \ @@ -56,6 +62,7 @@ void emb_test_skip(const char *why_flash); #define EMB_ASSERT_EQ(a, b) \ do { \ long va_ = (long)(a); \ + emb_test_asserted(); \ long vb_ = (long)(b); \ if (va_ != vb_) { \ emb_test_logf(EMB_TEST_STR(" values %ld and %ld"), va_, vb_); \ @@ -66,6 +73,7 @@ void emb_test_skip(const char *why_flash); #define EMB_ASSERT_STATUS(expr, expected) \ do { \ emb_status_t st_ = (expr); \ + emb_test_asserted(); \ if (st_ != (expected)) { \ emb_test_logf(EMB_TEST_STR(" status %d, expected %d"), (int)st_, (int)(expected)); \ emb_test_fail(__LINE__, EMB_TEST_STR("status: " #expr)); \ @@ -80,6 +88,7 @@ void emb_test_skip(const char *why_flash); #if EMB_TEST_HAS_FORK #define EMB_ASSERT_FAULTS(stmt) \ do { \ + emb_test_asserted(); \ if (emb_test_fork_begin()) { \ stmt; \ emb_test_fork_child_no_fault(); \ diff --git a/tools/evidence/collect.py b/tools/evidence/collect.py new file mode 100644 index 0000000..4570365 --- /dev/null +++ b/tools/evidence/collect.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Junior Deogracias +"""Collect individual conformance case outcomes from a build's CTest log (M2-04). + +Reads /Testing/Temporary/LastTest.log, which keeps every test executable's output, +whether it ran natively, under QEMU or on EmbSIM, and extracts the framework's evidence +lines (docs/engineering/case-evidence.md): + + EVIDENCE v1 suite= config= profile=

board= checked=<0|1> + TEST + skip: + req ... + CASE outcome= asserts= skips= + +and writes one JSON record per build: the source identity, the configuration, and per +executable its CTest result and its cases. A case that printed TEST without a CASE line +(the executable crashed or timed out inside it) is reported as "incomplete". +""" +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SCHEMA = "emblink-case-evidence" +VERSION = 1 + +BLOCK_START = re.compile(r"^\d+/\d+ Testing: (\S+)\s*$") +BLOCK_RESULT = re.compile(r"^Test (Passed|Failed)\.?\s*$") +EVIDENCE = re.compile(r"^EVIDENCE v1 (.*)$") +TEST = re.compile(r"^TEST (\S+)\s*$") +SKIP = re.compile(r"^ skip: (.*)$") +REQ = re.compile(r"^ req (.*)$") +CASE = re.compile(r"^CASE (\S+) outcome=(passed|failed|skipped) asserts=(\d+) skips=(\d+)\s*$") +FIELD = re.compile(r"(\w+)=(\S*)") + + +def parse_output(text: str) -> dict: + """The evidence of one executable's output.""" + ident, cases, current = {}, [], None + for line in text.splitlines(): + m = EVIDENCE.match(line) + if m: + ident = dict(FIELD.findall(m.group(1))) + continue + m = TEST.match(line) + if m: + if current is not None: + cases.append(current) # the previous case never reported its end + current = {"name": m.group(1), "outcome": "incomplete", "asserts": 0, "skips": 0, + "skip_reasons": [], "reqs": []} + continue + if current is None: + continue + m = SKIP.match(line) + if m: + current["skip_reasons"].append(m.group(1).strip()) + continue + m = REQ.match(line) + if m: + current["reqs"] = [r for r in m.group(1).split() if not r.startswith("+")] + continue + m = CASE.match(line) + if m and m.group(1) == current["name"]: + current.update(outcome=m.group(2), asserts=int(m.group(3)), skips=int(m.group(4))) + cases.append(current) + current = None + if current is not None: + cases.append(current) + return {"identity": ident, "cases": cases} + + +def parse_log(text: str) -> list: + """Split LastTest.log into executables with their CTest result and output.""" + targets, name, lines, in_output = [], None, [], False + for line in text.splitlines(): + m = BLOCK_START.match(line) + if m: + name, lines, in_output = m.group(1), [], False + continue + if name is None: + continue + if line.startswith("Output:"): + in_output = True + continue + if line.startswith(""): + in_output = False + continue + m = BLOCK_RESULT.match(line) + if m and not in_output: + ev = parse_output("\n".join(lines)) + targets.append({"name": name, "ctest": m.group(1).lower(), "suite": ev["identity"].get("suite"), + "identity": ev["identity"], "cases": ev["cases"]}) + name = None + continue + if in_output and not line.startswith("----------"): + lines.append(line) + return targets + + +def git_identity(root: Path) -> dict: + def git(*args): + return subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, + check=False).stdout.strip() + sha = git("rev-parse", "HEAD") + return {"sha": sha or None, "dirty": bool(git("status", "--porcelain", "--untracked-files=no"))} + + +def collect(build: Path, preset: str, root: Path = ROOT) -> dict: + log = build / "Testing" / "Temporary" / "LastTest.log" + targets = parse_log(log.read_text(encoding="utf-8", errors="replace")) + idents = {(t["identity"].get("config"), t["identity"].get("profile"), t["identity"].get("board"), + t["identity"].get("checked")) for t in targets if t["identity"]} + config = sorted(idents)[0] if len(idents) == 1 else None + return {"schema": SCHEMA, "version": VERSION, "preset": preset, "source": git_identity(root), + "config_hash": config[0] if config else None, "profile": config[1] if config else None, + "board": config[2] if config else None, "checked": config[3] == "1" if config else None, + "consistent_identity": len(idents) <= 1, "targets": targets} + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--build", required=True, type=Path, help="the build tree ctest ran in") + ap.add_argument("--preset", required=True, help="the configuration's name, e.g. native-gcc") + ap.add_argument("--out", required=True, type=Path, help="the JSON file to write") + args = ap.parse_args(argv) + try: + record = collect(args.build, args.preset) + except OSError as e: + print(f"collect: {e}", file=sys.stderr) + return 2 + args.out.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8") + cases = [c for t in record["targets"] for c in t["cases"]] + counts = {k: sum(1 for c in cases if c["outcome"] == k) for k in ("passed", "failed", "skipped", "incomplete")} + print(f"collect: {args.preset}: {len(record['targets'])} executables, {len(cases)} cases " + + ", ".join(f"{v} {k}" for k, v in counts.items())) + if not record["consistent_identity"]: + print("collect: executables report different configurations", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/evidence/configurations.json b/tools/evidence/configurations.json new file mode 100644 index 0000000..31f881c --- /dev/null +++ b/tools/evidence/configurations.json @@ -0,0 +1,25 @@ +{ + "_comment": "Execution scope of each configuration the requirement matrix reports (M2-04, docs/engineering/case-evidence.md). 'emulated' runs on QEMU or EmbSIM, 'host' natively; a configuration with 'unrun' has no executable evidence and states why.", + "configurations": { + "native-gcc": {"target": "native", "scope": "host", "profile": "base", "checked": true}, + "native-clang": {"target": "native", "scope": "host", "profile": "base", "checked": true}, + "native-release": {"target": "native", "scope": "host", "profile": "base", "checked": false}, + "native-tiny": {"target": "native", "scope": "host", "profile": "tiny", "checked": true}, + "native-periodic": {"target": "native", "scope": "host", "profile": "base", "checked": true}, + "cm3-an385": {"target": "qemu mps2-an385", "scope": "emulated", "profile": "base", "checked": true}, + "cm4-an386": {"target": "qemu mps2-an386", "scope": "emulated", "profile": "base", "checked": true}, + "cm4-an386-release": {"target": "qemu mps2-an386", "scope": "emulated", "profile": "base", "checked": false}, + "cm4-an386-tiny": {"target": "qemu mps2-an386", "scope": "emulated", "profile": "tiny", "checked": true}, + "cm4-nucleo-embsim": {"target": "embsim nucleo_f446re", "scope": "emulated", "profile": "base", "checked": true}, + "rv32-virt": {"target": "qemu virt rv32", "scope": "emulated", "profile": "base", "checked": true}, + "rv32-virt-release": {"target": "qemu virt rv32", "scope": "emulated", "profile": "base", "checked": false}, + "rv32-virt-tiny": {"target": "qemu virt rv32", "scope": "emulated", "profile": "tiny", "checked": true}, + "rv32-c3-embsim": {"target": "embsim esp32c3_devkitm1", "scope": "emulated", "profile": "base", "checked": true}, + "avr-uno": {"target": "qemu arduino-uno", "scope": "emulated", "profile": "tiny", "checked": true}, + "avr-uno-release": {"target": "qemu arduino-uno", "scope": "emulated", "profile": "tiny", "checked": false}, + "avr-uno-t1": {"target": "qemu arduino-uno", "scope": "emulated", "profile": "tiny", "checked": false}, + "avr-uno-embsim": {"target": "embsim arduino_uno", "scope": "emulated", "profile": "tiny", "checked": true}, + "nucleo-f446re-hil": {"target": "NUCLEO-F446RE board", "scope": "hardware", "profile": "base", "checked": true, + "unrun": "no board and no HIL runner yet (M3a)"} + } +} diff --git a/tools/evidence/matrix.py b/tools/evidence/matrix.py new file mode 100644 index 0000000..fbf93f4 --- /dev/null +++ b/tools/evidence/matrix.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Junior Deogracias +"""Requirement evidence matrix from collected case evidence (M2-04, TEST-009). + +Joins the requirement catalog (docs/requirements/*.md) with case evidence files written +by collect.py and the configuration inventory (configurations.json). For every +requirement and configuration it reports the four separate facts of +docs/engineering/timing-and-evidence.md: design status, evidence outcome, execution +scope and coverage type. + +A requirement counts as passed for a configuration only when an executed case that cites +it at run time passed with at least one assertion. Header @req tags, Verification fields +and IDs that merely appear in test sources are traceability links, never evidence. A +configuration without an evidence file is reported as not run, with its stated reason. + +Groups whose migration into docs/requirements/ is still pending (the README's group table) +have no catalog entries; cases that cite them keep their evidence in rows marked +"uncataloged" rather than being dropped. A citation outside every known group is an error. +""" +import argparse +import csv +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools" / "docs")) +from build import requirement_catalog # noqa: E402 + +OUTCOMES = ("failed", "passed", "skipped", "incomplete") + + +def pending_groups(readme: Path = ROOT / "docs" / "requirements" / "README.md") -> set: + """Requirement groups the README's table lists as pending migration.""" + groups = set() + for line in readme.read_text(encoding="utf-8").splitlines(): + cols = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cols) >= 3 and cols[2] == "pending": + groups.update(g.strip() for g in cols[0].split(",")) + return groups + + +def group_of(req: str) -> str: + return req.rsplit("-", 1)[0] + + +def cell(cases: list) -> str: + """The evidence outcome of one requirement in one configuration.""" + if not cases: + return "no case" + if any(c["outcome"] in ("failed", "incomplete") for c in cases): + return "failed" + if any(c["outcome"] == "passed" and c["asserts"] > 0 for c in cases): + return "passed" + if any(c["outcome"] == "skipped" for c in cases): + return "skipped" + return "no assertion" # executed, but checked nothing + + +def build_matrix(records: list, evidence: list, configs: dict) -> list: + by_preset = {e["preset"]: e for e in evidence} + cites = {} # (preset, requirement) -> [case] + for e in evidence: + for t in e["targets"]: + for c in t["cases"]: + for req in c["reqs"]: + cites.setdefault((e["preset"], req), []).append(dict(c, executable=t["name"])) + rows = [] + for r in records: + for preset, cfg in sorted(configs.items()): + if preset in by_preset: + cases = cites.get((preset, r["id"]), []) + outcome, scope = cell(cases), cfg["scope"] + else: + cases = [] + outcome = "not run" + scope = f"unrun: {cfg.get('unrun', 'no evidence file for this configuration')}" + rows.append({"requirement": r["id"], "design_status": r["design_status"], "configuration": preset, + "evidence_outcome": outcome, "execution_scope": scope, + "coverage_type": "conformance" if cases else "", + "cases": " ".join(sorted({f"{c['executable']}:{c['name']}" for c in cases}))}) + return rows + + +def summary(rows: list, configs: dict) -> dict: + out = {} + for preset in sorted(configs): + mine = [r for r in rows if r["configuration"] == preset] + out[preset] = {k: sum(1 for r in mine if r["evidence_outcome"] == k) + for k in ("passed", "failed", "skipped", "no assertion", "no case", "not run")} + return out + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("evidence", nargs="*", type=Path, help="files written by collect.py") + ap.add_argument("--configurations", type=Path, default=Path(__file__).with_name("configurations.json")) + ap.add_argument("--csv", type=Path, help="write the matrix as CSV") + ap.add_argument("--json", type=Path, help="write the matrix and its summary as JSON") + args = ap.parse_args(argv) + configs = json.loads(args.configurations.read_text(encoding="utf-8"))["configurations"] + evidence = [json.loads(p.read_text(encoding="utf-8")) for p in args.evidence] + unknown = sorted({e["preset"] for e in evidence} - set(configs)) + if unknown: + print(f"matrix: evidence for unlisted configurations {unknown}", file=sys.stderr) + return 2 + records = requirement_catalog() + known = {r["id"] for r in records} + cited = {req for e in evidence for t in e["targets"] for c in t["cases"] for req in c["reqs"]} + pending = pending_groups() + uncataloged = sorted(r for r in cited - known if group_of(r) in pending) + unknown_ids = sorted(r for r in cited - known if group_of(r) not in pending) + records = records + [{"id": r, "design_status": "uncataloged (migration pending)"} for r in uncataloged] + rows = build_matrix(records, evidence, configs) + totals = summary(rows, configs) + if args.csv: + with args.csv.open("w", newline="", encoding="utf-8") as f: + w = csv.DictWriter(f, fieldnames=list(rows[0])) + w.writeheader() + w.writerows(rows) + if args.json: + args.json.write_text(json.dumps({"summary": totals, "uncataloged": uncataloged, + "unknown_citations": unknown_ids, "rows": rows}, indent=2) + "\n", + encoding="utf-8") + for preset, t in totals.items(): + print(f"{preset}: " + ", ".join(f"{v} {k}" for k, v in t.items() if v)) + if uncataloged: + print(f"matrix: {len(uncataloged)} cited IDs belong to groups pending migration: {' '.join(uncataloged)}") + if unknown_ids: + print(f"matrix: cases cite unknown requirements {unknown_ids}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/evidence/test_evidence_tools.py b/tools/evidence/test_evidence_tools.py new file mode 100644 index 0000000..91a16b7 --- /dev/null +++ b/tools/evidence/test_evidence_tools.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Junior Deogracias +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import collect # noqa: E402 +import matrix # noqa: E402 + +LOG = """\ +Start testing: Oct 27 18:30 CET +---------------------------------------------------------- +1/2 Testing: test_sem +1/2 Test: test_sem +Command: "/build/tests/conformance/test_sem" +Output: +---------------------------------------------------------- +EVIDENCE v1 suite=test_sem config=abc123 profile=base board=native checked=1 +TEST sem_counts + -> ok + req KRN-SYNC-020 KRN-SYNC-021 +CASE sem_counts outcome=passed asserts=8 skips=0 +TEST sem_misuse +emb native: kernel fault class 5 code 3 argument 1 thread 0 context 0 at emb_sem_init, halting + skip: fault check needs fork + -> ok + req API-015 +CASE sem_misuse outcome=skipped asserts=0 skips=1 +3 tests, 0 failed, 1 skipped, 0 ticks + +Test time = 0.01 sec +---------------------------------------------------------- +Test Passed. +"test_sem" end time: Oct 27 18:30 CET +---------------------------------------------------------- +2/2 Testing: test_crash +2/2 Test: test_crash +Output: +---------------------------------------------------------- +EVIDENCE v1 suite=test_crash config=abc123 profile=base board=native checked=1 +TEST crash_case + req KRN-OBJ-007 + +Test Failed. +""" + + +class Collect(unittest.TestCase): + def test_cases_reqs_and_outcomes(self): + targets = collect.parse_log(LOG) + self.assertEqual([(t["name"], t["ctest"], t["suite"]) for t in targets], + [("test_sem", "passed", "test_sem"), ("test_crash", "failed", "test_crash")]) + sem = targets[0]["cases"] + self.assertEqual(sem[0], {"name": "sem_counts", "outcome": "passed", "asserts": 8, "skips": 0, + "skip_reasons": [], "reqs": ["KRN-SYNC-020", "KRN-SYNC-021"]}) + self.assertEqual((sem[1]["outcome"], sem[1]["skip_reasons"], sem[1]["reqs"]), + ("skipped", ["fault check needs fork"], ["API-015"])) + self.assertEqual(targets[0]["identity"]["config"], "abc123") + + def test_case_without_end_is_incomplete(self): + crash = collect.parse_log(LOG)[1]["cases"] + self.assertEqual([(c["name"], c["outcome"]) for c in crash], [("crash_case", "incomplete")]) + + +class Matrix(unittest.TestCase): + def test_cell_needs_an_executed_assertion(self): + passed = {"outcome": "passed", "asserts": 2} + self.assertEqual(matrix.cell([]), "no case") + self.assertEqual(matrix.cell([passed]), "passed") + self.assertEqual(matrix.cell([{"outcome": "passed", "asserts": 0}]), "no assertion") + self.assertEqual(matrix.cell([{"outcome": "skipped", "asserts": 0}]), "skipped") + self.assertEqual(matrix.cell([passed, {"outcome": "failed", "asserts": 1}]), "failed") + self.assertEqual(matrix.cell([passed, {"outcome": "incomplete", "asserts": 0}]), "failed") + + def test_rows_keep_design_status_and_unrun_reasons_apart(self): + records = [{"id": "KRN-SYNC-020", "design_status": "Accepted"}] + evidence = [{"preset": "native-gcc", "targets": collect.parse_log(LOG)}] + configs = {"native-gcc": {"scope": "host"}, "board": {"scope": "hardware", "unrun": "no board"}} + rows = {r["configuration"]: r for r in matrix.build_matrix(records, evidence, configs)} + self.assertEqual((rows["native-gcc"]["design_status"], rows["native-gcc"]["evidence_outcome"], + rows["native-gcc"]["execution_scope"], rows["native-gcc"]["cases"]), + ("Accepted", "passed", "host", "test_sem:sem_counts")) + self.assertEqual((rows["board"]["evidence_outcome"], rows["board"]["execution_scope"]), + ("not run", "unrun: no board")) + + def test_pending_groups_come_from_the_requirements_readme(self): + pending = matrix.pending_groups() + self.assertIn("KRN-SCH", pending) + self.assertNotIn("KRN-SYNC", pending) + + +if __name__ == "__main__": + unittest.main() From 0810d6465c00d0a3adb618f33e4e1abec8020c29 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 18:48:30 +0100 Subject: [PATCH 12/30] docs(bounds): inventory every non-constant API cost (M2-04) The public headers' @time annotations name cost inputs (waiters, armed timeouts, owned mutexes, PI work, table scans, batch wakes, callbacks, and so on), but nothing said how each input is bounded. Nothing noticed, either, when an annotation named an input no one had thought about. docs/engineering/resource-bounds.json is the reviewed inventory. It has 14 entries, each with its input, bound, limit, enforcement (configuration, admission, structural or application), whether it runs masked, its profile differences and its evidence. It maps the 25 public functions whose @time is not O(1) to the entries they depend on. Inputs the kernel does not limit (owned mutexes, active objects, stack size, callbacks) are marked as application obligations rather than presented as bounded. tools/evidence/check_bounds.py, run in the lint job, fails when a non-O(1) annotation has no entry, when an entry is incomplete or unknown, or when the rendered resource-bounds.md is stale. Signed-off-by: teo1747 --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 2 + docs/engineering/index.md | 1 + docs/engineering/m2-checklist.md | 2 +- docs/engineering/resource-bounds.json | 105 ++++++++++++++++++++++++ docs/engineering/resource-bounds.md | 52 ++++++++++++ docs/engineering/timing-and-evidence.md | 2 + tools/evidence/check_bounds.py | 102 +++++++++++++++++++++++ tools/evidence/test_evidence_tools.py | 27 ++++++ 9 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 docs/engineering/resource-bounds.json create mode 100644 docs/engineering/resource-bounds.md create mode 100644 tools/evidence/check_bounds.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4650df1..38c2c1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,6 +228,8 @@ jobs: run: python3 -I -m unittest discover -s tools/footprint -p 'test_*.py' -v - name: Case evidence tools run: python3 -I -m unittest discover -s tools/evidence -p 'test_*.py' -v + - name: Resource-bound inventory covers every non-constant cost + run: python3 -I tools/evidence/check_bounds.py - name: Configuration tool self-check run: | python3 -I tools/kconfig/embconfig.py --kconfig Kconfig --manifest arch/native/arch.yaml \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab1936..c7816b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Added +- A reviewed resource-bound inventory (`docs/engineering/resource-bounds.json` and its page) for every cost input in the public `@time` annotations, with `tools/evidence/check_bounds.py` failing CI when a non-constant cost has no entry (M2-04). + - Executed case evidence (M2-04): conformance runs print each case's requirement citations and outcome with assertion and skip counts; `tools/evidence/collect.py` gathers them from the CTest log of any port, CI uploads them per configuration, and `tools/evidence/matrix.py` derives the requirement-by-configuration matrix from executed, asserting cases only. - `emb_sem_unbind_notify()` (KRN-NOTIF-013). A thread counts the bindings that name it, and `emb_thread_destroy()` refuses it while any does; `emb_sem_bind_notify()` refuses terminated (`EMB_ESTATE`) and kernel (`EMB_EPERM`) targets (KRN-NOTIF-014, ADR-046). The reference model and the differential bridge cover bind, unbind and thread destroy. diff --git a/docs/engineering/index.md b/docs/engineering/index.md index 22ba915..78f32ce 100644 --- a/docs/engineering/index.md +++ b/docs/engineering/index.md @@ -19,6 +19,7 @@ m2-02-thread-identity m2-02-fault-api-ids m2-02-object-lifecycle case-evidence +resource-bounds embcc-capability-review embcc-layout-marker embcc-native-linux diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index 41a0902..dfe3b83 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -60,7 +60,7 @@ The current docs disclose these gaps. Correcting one must update its contract, r - [x] Export individual conformance outcomes with requirement IDs, assertions/analyses, configuration hash, source SHA, skips and unrun reasons. The framework prints `EVIDENCE`, `req` and `CASE` lines; `tools/evidence/collect.py` records them with the source identity, and every CI test job uploads them (see [case evidence](case-evidence.md)). - [x] Validate declared mappings against actual executed cases rather than counting header/test ID strings. Migrate legacy requirement definitions deliberately when their owning subsystem needs them. `tools/evidence/matrix.py` counts only citations printed by executed, asserting cases. The fourteen cited `KRN-SCH`/`KRN-MEM` identifiers of groups pending migration stay visible as uncataloged rows; their v0.1 text is not in the repository, so their migration waits for the scheduler and memory requirement work rather than being reconstructed. -- [ ] Inventory admitted waiters, armed timeouts, owned mutexes, inheritance depth, tiny table scans and callbacks/batch wakes. +- [x] Inventory admitted waiters, armed timeouts, owned mutexes, inheritance depth, tiny table scans and callbacks/batch wakes. The [resource-bound inventory](resource-bounds.md) states each input's bound, limit, enforcement and whether it runs masked; `tools/evidence/check_bounds.py`, run by the lint job, fails when a public function's non-O(1) `@time` names no inventory entry. Measured or analyzed worst cases attach to the entries later. - [x] Define feature/configuration applicability for each case and analysis. Accepted design status remains separate from evidence outcome. A case applies where its feature gate compiles it; `tools/evidence/configurations.json` gives every configuration its execution scope or unrun reason, and the matrix keeps design status and evidence outcome in separate columns. Telling an out-of-scope `no case` from a gap automatically is not yet done. The [timing/evidence contract](timing-and-evidence.md) controls these artifacts. Physical timing and full release reproducibility remain the subsequent hardware/release gates, not inferred results of this export. diff --git a/docs/engineering/resource-bounds.json b/docs/engineering/resource-bounds.json new file mode 100644 index 0000000..ade0ac7 --- /dev/null +++ b/docs/engineering/resource-bounds.json @@ -0,0 +1,105 @@ +{ + "schema": "emblink-resource-bounds", + "version": 1, + "_comment": "The reviewed inventory of every cost input in the public API's @time annotations (M2-04). tools/evidence/check_bounds.py checks that every public function whose @time is not O(1) maps to entries here and renders resource-bounds.md from this file. Enforcement: 'configuration' (a Kconfig limit), 'admission' (the kernel refuses beyond it), 'structural' (follows from another bound) or 'application' (the kernel does not limit it; the application must). 'masked' says whether the cost runs with interrupts masked.", + "bounds": [ + {"id": "waiters", "input": "threads waiting on one wait queue", + "bound": "at most the live threads: a thread waits on at most one queue", + "limit": "base: 254 thread IDs; tiny: CONFIG_EMB_PRIORITY_COUNT - 1 threads", "enforcement": "structural", + "masked": true, + "profiles": "base: PRIORITY_FIFO insert walks from the tail, O(waiters), FIFO O(1); tiny: bitmap set O(1), first waiter O(1) or the raised-thread scan", + "evidence": "SPEC-004 §11; the reference model's queue-order invariant"}, + {"id": "timeouts", "input": "armed timeouts", + "bound": "at most one per thread, so at most the live threads", + "limit": "as for waiters", "enforcement": "structural", "masked": true, + "profiles": "base: doubly linked list, insert O(armed) from the tail, removal O(1); tiny: singly linked list, insert and removal O(armed)", + "evidence": "SPEC-003 §5.1, SPEC-012 §13; the model's one-timeout-per-thread invariant"}, + {"id": "owned", "input": "mutexes one thread owns", + "bound": "not limited by the kernel: at most the mutex objects the application creates", + "limit": "application", "enforcement": "application", "masked": true, + "profiles": "the same singly linked owned list on every profile; acquisition appends, release and recomputation walk it", + "evidence": "SPEC-005 §2.3; A25"}, + {"id": "pi_depth", "input": "inheritance chain hops (PI work)", + "bound": "CONFIG_EMB_PI_MAX_DEPTH hops; each hop recomputes O(owned) and requeues O(waiters)", + "limit": "CONFIG_EMB_PI_MAX_DEPTH (default 8, 1 to 32)", "enforcement": "configuration", "masked": true, + "profiles": "checked builds fault at the limit, release builds stop propagating (no complete inheritance beyond it); deadlock detection inspects the same number of links", + "evidence": "SPEC-005 §2.2, A25; m2-02-contract-review.md; model PI_MAX_DEPTH"}, + {"id": "table_scan", "input": "tiny raised-priority scan", + "bound": "CONFIG_EMB_PRIORITY_COUNT - 1 slots, only while some thread runs above its base priority", + "limit": "CONFIG_EMB_PRIORITY_COUNT, at most the architecture word width", "enforcement": "configuration", + "masked": true, "profiles": "tiny only; base selects through the per-priority bitmap", + "evidence": "ADR-036; kernel/sched/sched.c embk_sched_table_best"}, + {"id": "reschedule", "input": "a scheduling decision", + "bound": "O(1) on base (bitmap and per-priority lists); the table scan on tiny", + "limit": "see table_scan", "enforcement": "structural", "masked": true, + "profiles": "one decision per preemption point; batch wakes reschedule once", + "evidence": "SPEC-002 §6; KRN-WAIT-013"}, + {"id": "batch_wake", "input": "waiters woken at once (destroy with EMB_OBJ_ABORT_WAITERS)", + "bound": "the waiters of the destroyed object", + "limit": "as for waiters", "enforcement": "structural", "masked": true, + "profiles": "one section, one reschedule at the end", + "evidence": "SPEC-004 §6.7; KRN-OBJ-001"}, + {"id": "thread_ids", "input": "diagnostic identity scan at thread init", + "bound": "254 IDs on list profiles, CONFIG_EMB_PRIORITY_COUNT - 1 on tiny", + "limit": "fixed per profile; EMB_ENOMEM beyond it", "enforcement": "admission", "masked": true, + "profiles": "bitmap scan from the lowest ID", + "evidence": "ADR-043; m2-02-thread-identity.md"}, + {"id": "live_threads", "input": "threads on the all-threads list", + "bound": "at most 254 (the ID space)", + "limit": "CONFIG_EMB_THREAD_LIST; the ID admission", "enforcement": "admission", "masked": true, + "profiles": "base only (no list on tiny); walked by thread init and emb_thread_foreach", + "evidence": "SPEC-008 §12; ADR-045"}, + {"id": "active_objects", "input": "active semaphores or mutexes of one type", + "bound": "not limited by the kernel: the objects the application initializes", + "limit": "application", "enforcement": "application", "masked": true, + "profiles": "with CONFIG_EMB_OBJ_LIST only (not on tiny); walked by init", + "evidence": "ADR-045; m2-02-object-lifecycle.md"}, + {"id": "stack", "input": "bytes of one thread's stack", + "bound": "the stack size the application supplied", + "limit": "application", "enforcement": "application", "masked": false, + "profiles": "the high-water scan exists only with CONFIG_EMB_STACK_STATS; init fills the stack in checked and statistics builds", + "evidence": "SPEC-008 §10; KRN-MEM-009"}, + {"id": "init_table", "input": "entries of the initialization table", + "bound": "the EMB_*_DEFINE objects linked into the image", + "limit": "application, fixed at link time", "enforcement": "application", "masked": false, + "profiles": "prekernel only", + "evidence": "KRN-OBJ-009"}, + {"id": "callbacks", "input": "application callbacks the kernel calls (fault hook, trace sink, foreach visitor)", + "bound": "not bounded by the kernel", + "limit": "application", "enforcement": "application", "masked": true, + "profiles": "the fault hook and the trace sink can run with interrupts masked; the foreach visitor runs under the scheduler lock", + "evidence": "API-017; SPEC-015"}, + {"id": "port_action", "input": "the port's fault action (halt or reset)", + "bound": "does not return", + "limit": "port", "enforcement": "structural", "masked": true, + "profiles": "CONFIG_EMB_FAULT_ACTION", + "evidence": "03 §10; SPEC-011 §14"} + ], + "apis": { + "emb_fault_hook": ["callbacks"], + "emb_fault": ["callbacks", "port_action"], + "emb_kernel_init": ["init_table"], + "emb_mutex_init": ["active_objects"], + "emb_mutex_destroy": ["batch_wake", "owned", "pi_depth", "timeouts", "reschedule"], + "emb_mutex_lock": ["owned", "waiters", "pi_depth", "timeouts"], + "emb_mutex_lock_until": ["owned", "waiters", "pi_depth", "timeouts"], + "emb_mutex_unlock": ["owned", "pi_depth", "timeouts", "reschedule"], + "emb_notify_set": ["timeouts", "reschedule"], + "emb_notify_wait": ["timeouts"], + "emb_notify_wait_until": ["timeouts"], + "emb_sched_unlock": ["reschedule"], + "emb_sem_init": ["active_objects"], + "emb_sem_destroy": ["batch_wake", "reschedule"], + "emb_sem_take": ["waiters", "timeouts"], + "emb_sem_take_until": ["waiters", "timeouts"], + "emb_sem_give": ["timeouts", "reschedule"], + "emb_thread_init": ["thread_ids", "live_threads", "stack"], + "emb_thread_exit": ["owned", "pi_depth", "timeouts"], + "emb_thread_join": ["timeouts"], + "emb_thread_set_priority": ["pi_depth", "waiters", "reschedule", "table_scan"], + "emb_thread_sleep": ["timeouts"], + "emb_thread_sleep_until": ["timeouts"], + "emb_thread_stack_info": ["stack"], + "emb_thread_foreach": ["live_threads", "callbacks", "reschedule"] + } +} diff --git a/docs/engineering/resource-bounds.md b/docs/engineering/resource-bounds.md new file mode 100644 index 0000000..6cc4b65 --- /dev/null +++ b/docs/engineering/resource-bounds.md @@ -0,0 +1,52 @@ + + +# Resource-bound inventory + +Every cost input named by a public function's `@time` annotation, with how it is bounded. The inventory is reviewed, not derived: the checker proves only that it is complete against the annotations. Analysis and measured worst cases attach to these entries as they are produced; [timing and evidence](timing-and-evidence.md) defines how a claim becomes substantiated. + +| Input | Bound | Limit | Enforcement | Masked | Profiles | Evidence | +|---|---|---|---|---|---|---| +| **waiters**: threads waiting on one wait queue | at most the live threads: a thread waits on at most one queue | base: 254 thread IDs; tiny: CONFIG_EMB_PRIORITY_COUNT - 1 threads | structural | yes | base: PRIORITY_FIFO insert walks from the tail, O(waiters), FIFO O(1); tiny: bitmap set O(1), first waiter O(1) or the raised-thread scan | SPEC-004 §11; the reference model's queue-order invariant | +| **timeouts**: armed timeouts | at most one per thread, so at most the live threads | as for waiters | structural | yes | base: doubly linked list, insert O(armed) from the tail, removal O(1); tiny: singly linked list, insert and removal O(armed) | SPEC-003 §5.1, SPEC-012 §13; the model's one-timeout-per-thread invariant | +| **owned**: mutexes one thread owns | not limited by the kernel: at most the mutex objects the application creates | application | application | yes | the same singly linked owned list on every profile; acquisition appends, release and recomputation walk it | SPEC-005 §2.3; A25 | +| **pi_depth**: inheritance chain hops (PI work) | CONFIG_EMB_PI_MAX_DEPTH hops; each hop recomputes O(owned) and requeues O(waiters) | CONFIG_EMB_PI_MAX_DEPTH (default 8, 1 to 32) | configuration | yes | checked builds fault at the limit, release builds stop propagating (no complete inheritance beyond it); deadlock detection inspects the same number of links | SPEC-005 §2.2, A25; m2-02-contract-review.md; model PI_MAX_DEPTH | +| **table_scan**: tiny raised-priority scan | CONFIG_EMB_PRIORITY_COUNT - 1 slots, only while some thread runs above its base priority | CONFIG_EMB_PRIORITY_COUNT, at most the architecture word width | configuration | yes | tiny only; base selects through the per-priority bitmap | ADR-036; kernel/sched/sched.c embk_sched_table_best | +| **reschedule**: a scheduling decision | O(1) on base (bitmap and per-priority lists); the table scan on tiny | see table_scan | structural | yes | one decision per preemption point; batch wakes reschedule once | SPEC-002 §6; KRN-WAIT-013 | +| **batch_wake**: waiters woken at once (destroy with EMB_OBJ_ABORT_WAITERS) | the waiters of the destroyed object | as for waiters | structural | yes | one section, one reschedule at the end | SPEC-004 §6.7; KRN-OBJ-001 | +| **thread_ids**: diagnostic identity scan at thread init | 254 IDs on list profiles, CONFIG_EMB_PRIORITY_COUNT - 1 on tiny | fixed per profile; EMB_ENOMEM beyond it | admission | yes | bitmap scan from the lowest ID | ADR-043; m2-02-thread-identity.md | +| **live_threads**: threads on the all-threads list | at most 254 (the ID space) | CONFIG_EMB_THREAD_LIST; the ID admission | admission | yes | base only (no list on tiny); walked by thread init and emb_thread_foreach | SPEC-008 §12; ADR-045 | +| **active_objects**: active semaphores or mutexes of one type | not limited by the kernel: the objects the application initializes | application | application | yes | with CONFIG_EMB_OBJ_LIST only (not on tiny); walked by init | ADR-045; m2-02-object-lifecycle.md | +| **stack**: bytes of one thread's stack | the stack size the application supplied | application | application | no | the high-water scan exists only with CONFIG_EMB_STACK_STATS; init fills the stack in checked and statistics builds | SPEC-008 §10; KRN-MEM-009 | +| **init_table**: entries of the initialization table | the EMB_*_DEFINE objects linked into the image | application, fixed at link time | application | no | prekernel only | KRN-OBJ-009 | +| **callbacks**: application callbacks the kernel calls (fault hook, trace sink, foreach visitor) | not bounded by the kernel | application | application | yes | the fault hook and the trace sink can run with interrupts masked; the foreach visitor runs under the scheduler lock | API-017; SPEC-015 | +| **port_action**: the port's fault action (halt or reset) | does not return | port | structural | yes | CONFIG_EMB_FAULT_ACTION | 03 §10; SPEC-011 §14 | + +## Public functions + +| Function | `@time` | Inputs | +|---|---|---| +| `emb_fault()` | O(1) preparation plus trace/hook and the non-returning port action | callbacks, port_action | +| `emb_fault_hook()` | the application's | callbacks | +| `emb_kernel_init()` | O(init table) | init_table | +| `emb_mutex_destroy()` | O(waiters + owned + PI work), plus profile-specific timeout removals | batch_wake, owned, pi_depth, timeouts, reschedule | +| `emb_mutex_init()` | O(1); O(active mutexes) list lookup with CONFIG_EMB_OBJ_LIST | active_objects | +| `emb_mutex_lock()` | O(owned) acquisition; O(waiters + PI work + timeouts) finite contended wait | owned, waiters, pi_depth, timeouts | +| `emb_mutex_lock_until()` | O(owned) acquisition; enqueue/PI/timeout costs as emb_mutex_lock() | owned, waiters, pi_depth, timeouts | +| `emb_mutex_unlock()` | O(owned + PI work), plus waiter hand-off and profile-specific timeout removal | owned, pi_depth, timeouts, reschedule | +| `emb_notify_set()` | O(1) bit update, plus profile-specific timeout removal and scheduling | timeouts, reschedule | +| `emb_notify_wait()` | O(1) bit test; O(timeouts) finite timeout arm | timeouts | +| `emb_notify_wait_until()` | O(1) bit test; O(timeouts) finite timeout arm | timeouts | +| `emb_sched_unlock()` | O(1), plus one reschedule at depth 0 | reschedule | +| `emb_sem_destroy()` | O(waiters) with EMB_OBJ_ABORT_WAITERS | batch_wake, reschedule | +| `emb_sem_give()` | O(1) count/selection, plus profile-specific timeout removal and scheduling | timeouts, reschedule | +| `emb_sem_init()` | O(1); O(active semaphores) list lookup with CONFIG_EMB_OBJ_LIST | active_objects | +| `emb_sem_take()` | O(1) fast path; O(waiters + timeouts) finite wait enqueue | waiters, timeouts | +| `emb_sem_take_until()` | O(1) fast path; O(waiters + timeouts) finite wait enqueue | waiters, timeouts | +| `emb_thread_exit()` | O(owned mutexes) | owned, pi_depth, timeouts | +| `emb_thread_foreach()` | O(threads) plus callback costs and any reschedule at final unlock | live_threads, callbacks, reschedule | +| `emb_thread_init()` | bounded scan of at most 254 IDs + O(live threads) with CONFIG_EMB_THREAD_LIST + stack fill when configured | thread_ids, live_threads, stack | +| `emb_thread_join()` | O(1) state check; O(timeouts) finite timeout arm | timeouts | +| `emb_thread_set_priority()` | O(PI work), plus waiter requeue and profile-specific scheduling | pi_depth, waiters, reschedule, table_scan | +| `emb_thread_sleep()` | O(timeouts) arm | timeouts | +| `emb_thread_sleep_until()` | O(timeouts) arm | timeouts | +| `emb_thread_stack_info()` | O(stack) scan with statistics on | stack | diff --git a/docs/engineering/timing-and-evidence.md b/docs/engineering/timing-and-evidence.md index cc5514f..ffd578d 100644 --- a/docs/engineering/timing-and-evidence.md +++ b/docs/engineering/timing-and-evidence.md @@ -68,6 +68,8 @@ For deep replay failures, save the exact runner/model build, full trace, scenari A historical report whose input and build cannot be recovered cannot be classified. After a bounded, recorded reproduction attempt, a maintainer may close it with the disposition **not reproducible: input unavailable, cause unknown**. This is not one of the four classifications, not a fix, and not evidence that the reported behavior cannot occur. The closing record states the attempt's scope, names the triggers that reopen it, and keeps the report listed among the known limits in Project status and release notes. +The bounded inputs those annotations name, with their limits and how each is enforced, are listed in the [resource-bound inventory](resource-bounds.md), which the lint job checks for completeness against the public headers. + ## Memory and reproducibility evidence Report kernel, architecture, board, application and runtime subtotals alongside the complete linked flash image. Include `.data` initialization images in flash, live `.data`/`.bss`, alignment/padding, metadata and retained sections. Distinguish reserved from loaded sections. diff --git a/tools/evidence/check_bounds.py b/tools/evidence/check_bounds.py new file mode 100644 index 0000000..3ecc29c --- /dev/null +++ b/tools/evidence/check_bounds.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Junior Deogracias +"""Check the resource-bound inventory against the public API (M2-04). + +Every public function whose @time annotation is not exactly "O(1)" must name its cost +inputs in docs/engineering/resource-bounds.json, every name must be an inventory entry, +and every entry must state its input, bound, limit, enforcement and whether it runs +masked. The inventory's rendered page, resource-bounds.md, must match the JSON +(--write regenerates it). Exit status 1 on any mismatch. +""" +import argparse +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools" / "docs")) +from build import contract_tags # noqa: E402 + +INVENTORY = ROOT / "docs" / "engineering" / "resource-bounds.json" +PAGE = ROOT / "docs" / "engineering" / "resource-bounds.md" +FIELDS = ("id", "input", "bound", "limit", "enforcement", "masked", "profiles", "evidence") +ENFORCEMENT = {"configuration", "admission", "structural", "application"} +BLOCK = re.compile(r"/\*\*\s*\n\s*\* (emb_[a-z0-9_]+)\(\) -.*?\*/", re.S) + + +def annotated_costs(include: Path = ROOT / "include" / "emb") -> dict: + """Public function -> its @time text, for every non-O(1) annotation.""" + out = {} + for header in sorted(include.glob("*.h")): + for m in BLOCK.finditer(header.read_text(encoding="utf-8")): + t = contract_tags(m.group(0)).get("time", "") + if t != "O(1)": + out[m.group(1)] = t + return out + + +def problems(inv: dict, costs: dict) -> list: + errs = [] + ids = set() + for b in inv["bounds"]: + missing = [f for f in FIELDS if f not in b or b[f] in ("", None)] + if missing: + errs.append(f"bound {b.get('id', '?')}: missing {', '.join(missing)}") + if b.get("enforcement") not in ENFORCEMENT: + errs.append(f"bound {b.get('id')}: enforcement {b.get('enforcement')!r} not one of {sorted(ENFORCEMENT)}") + if b.get("id") in ids: + errs.append(f"bound {b['id']}: duplicate") + ids.add(b.get("id")) + for fn in sorted(set(costs) - set(inv["apis"])): + errs.append(f"{fn}: @time {costs[fn]!r} has no inventory entry") + for fn in sorted(set(inv["apis"]) - set(costs)): + errs.append(f"{fn}: listed in the inventory but its @time is O(1) or it is not public") + for fn, refs in sorted(inv["apis"].items()): + for ref in refs: + if ref not in ids: + errs.append(f"{fn}: unknown bound {ref!r}") + return errs + + +def render(inv: dict, costs: dict) -> str: + lines = ["", + "", "# Resource-bound inventory", "", + "Every cost input named by a public function's `@time` annotation, with how it is bounded. " + "The inventory is reviewed, not derived: the checker proves only that it is complete " + "against the annotations. Analysis and measured worst cases attach to these entries as they " + "are produced; [timing and evidence](timing-and-evidence.md) defines how a claim becomes " + "substantiated.", "", + "| Input | Bound | Limit | Enforcement | Masked | Profiles | Evidence |", + "|---|---|---|---|---|---|---|"] + for b in inv["bounds"]: + lines.append(f"| **{b['id']}**: {b['input']} | {b['bound']} | {b['limit']} | {b['enforcement']} | " + f"{'yes' if b['masked'] else 'no'} | {b['profiles']} | {b['evidence']} |") + lines += ["", "## Public functions", "", "| Function | `@time` | Inputs |", "|---|---|---|"] + for fn in sorted(inv["apis"]): + lines.append(f"| `{fn}()` | {costs.get(fn, '')} | {', '.join(inv['apis'][fn])} |") + return "\n".join(lines) + "\n" + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--write", action="store_true", help="regenerate resource-bounds.md") + args = ap.parse_args(argv) + inv = json.loads(INVENTORY.read_text(encoding="utf-8")) + costs = annotated_costs() + errs = problems(inv, costs) + page = render(inv, costs) + if args.write: + PAGE.write_text(page, encoding="utf-8") + elif not PAGE.exists() or PAGE.read_text(encoding="utf-8") != page: + errs.append(f"{PAGE.relative_to(ROOT)} is out of date: run check_bounds.py --write") + for e in errs: + print(f"check_bounds: {e}", file=sys.stderr) + if not errs: + print(f"check_bounds: {len(inv['bounds'])} bounds cover {len(costs)} non-O(1) public functions") + return 1 if errs else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/evidence/test_evidence_tools.py b/tools/evidence/test_evidence_tools.py index 91a16b7..35a2cee 100644 --- a/tools/evidence/test_evidence_tools.py +++ b/tools/evidence/test_evidence_tools.py @@ -92,3 +92,30 @@ def test_pending_groups_come_from_the_requirements_readme(self): if __name__ == "__main__": unittest.main() + + +import check_bounds # noqa: E402 + + +class Bounds(unittest.TestCase): + BOUND = {"id": "waiters", "input": "x", "bound": "y", "limit": "z", "enforcement": "structural", + "masked": True, "profiles": "p", "evidence": "e"} + + def test_every_non_constant_cost_needs_known_inputs(self): + inv = {"bounds": [self.BOUND], "apis": {"emb_a": ["waiters"], "emb_b": ["nonsense"], "emb_c": ["waiters"]}} + errs = check_bounds.problems(inv, {"emb_a": "O(waiters)", "emb_b": "O(n)", "emb_d": "O(n)"}) + self.assertTrue(any("emb_d" in e and "no inventory entry" in e for e in errs)) + self.assertTrue(any("emb_b" in e and "nonsense" in e for e in errs)) + self.assertTrue(any("emb_c" in e and "O(1) or it is not public" in e for e in errs)) + self.assertFalse(any("emb_a" in e for e in errs)) + + def test_entries_need_every_field_and_a_known_enforcement(self): + bad = dict(self.BOUND, enforcement="hope", limit="") + errs = check_bounds.problems({"bounds": [bad], "apis": {}}, {}) + self.assertTrue(any("missing limit" in e for e in errs)) + self.assertTrue(any("enforcement 'hope'" in e for e in errs)) + + def test_the_repository_inventory_is_complete(self): + import json + inv = json.loads(check_bounds.INVENTORY.read_text(encoding="utf-8")) + self.assertEqual(check_bounds.problems(inv, check_bounds.annotated_costs()), []) From a78f8f7c771cb6765ffef275fa8dc3aa30a665d5 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 19:02:55 +0100 Subject: [PATCH 13/30] feat(sync): event flags (M2-05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Event flags hold a word of bits that threads wait on with ANY or ALL of a mask, optionally clearing the bits that satisfied them (SPEC-005 §5, KRN-SYNC-029). Set and clear are ISR-safe; waits are thread-only with the usual timeout, absolute-deadline, cancellation and destroy outcomes. - Set ORs the bits and walks the waiters once in queue order. Each satisfied waiter is handed its bits, and a CLEAR waiter removes them before the next waiter is examined, so overlapping clearing waiters are served highest priority first. All of it runs in one section with one reschedule. A new embk_wait_iter walks list and bitmap queues in the same order as embk_wait_first(). - A waiter keeps its mask in wake_data and its mode in the thread's bit-wait mode bits, shared with notification waits (A38). The TCB does not grow. - A binding (KRN-SYNC-035) fires only for bits still set and changed after the direct waiters were served; unbind follows ADR-046. - A zero mask or an unknown mode bit is argument misuse; set and clear of zero bits are no-ops (A39). emb_event_attr_default() and unbind are added; there is no DEFINE macro yet (A40). - CONFIG_EMB_EVENT (on) and CONFIG_EMB_EVENT_BITS (8 tiny, 32 base). The footprint_t1 reference configuration leaves events out, and its footprint is unchanged. Storage, the ABI inventory, API IDs 85 to 94 and trace IDs 31/32 are added; NOTIFY_SET's trace comment now names the bound object (A35). The reference model gains Event state and EventSet/EventClear/ EventWait/EventDestroy, the invariant that no queued waiter is satisfied by the current bits, and an EVENT scenario group with exhaustive property tests. The runner and bridge carry event flags; existing encodings are unchanged, and a missing "events" key in archived captures reads as not configured. tests/conformance/ test_event.c covers the contract on every port. Signed-off-by: teo1747 --- CHANGELOG.md | 2 + configs/footprint-t1.conf | 1 + docs/STATUS.md | 4 +- docs/engineering/m2-checklist.md | 2 +- docs/engineering/resource-bounds.json | 360 ++++++++++++++++++------- docs/engineering/resource-bounds.md | 5 + docs/requirements/KRN-SYNC.md | 8 +- docs/specs/AMENDMENTS-M2.md | 3 + docs/specs/SPEC-005-synchronization.md | 7 +- docs/user/feature-status.md | 4 +- docs/user/synchronization.md | 21 ++ include/emb/api_ids.h | 10 + include/emb/emb.h | 3 + include/emb/event.h | 243 +++++++++++++++++ include/emb/trace.h | 8 +- kernel/CMakeLists.txt | 3 + kernel/Kconfig | 11 + kernel/include/embk/kernel.h | 6 +- kernel/include/embk/sync.h | 19 ++ kernel/include/embk/wait.h | 14 + kernel/object/storage_probe.c | 3 + kernel/sync/event.c | 285 ++++++++++++++++++++ kernel/wait/wait.c | 28 ++ tests/conformance/CMakeLists.txt | 3 + tests/conformance/test_event.c | 189 +++++++++++++ tests/differential/diff_runner.c | 149 +++++++++- tools/fault/api-ids.json | 60 +++++ tools/fault/api-registry.json | 60 +++++ tools/model/bridge.py | 37 ++- tools/model/embmodel/__init__.py | 3 +- tools/model/embmodel/kernel.py | 138 +++++++++- tools/model/evidence.py | 9 +- tools/model/tests/scenarios.py | 58 ++++ tools/model/tests/test_explore.py | 50 ++++ tools/toolchain/native_abi.c | 6 + 35 files changed, 1679 insertions(+), 133 deletions(-) create mode 100644 include/emb/event.h create mode 100644 kernel/sync/event.c create mode 100644 tests/conformance/test_event.c diff --git a/CHANGELOG.md b/CHANGELOG.md index c7816b3..bfca986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Added +- Event flags (`CONFIG_EMB_EVENT`, `include/emb/event.h`, M2-05): ANY/ALL waits with optional CLEAR, deterministic consumption in queue order, ISR-safe set and clear, timeouts, cancellation, destroy with ABORT_WAITERS, and notification binding with unbind. The reference model, the differential bridge and a conformance suite cover them; they are off in the tiny `footprint_t1` reference configuration. + - A reviewed resource-bound inventory (`docs/engineering/resource-bounds.json` and its page) for every cost input in the public `@time` annotations, with `tools/evidence/check_bounds.py` failing CI when a non-constant cost has no entry (M2-04). - Executed case evidence (M2-04): conformance runs print each case's requirement citations and outcome with assertion and skip counts; `tools/evidence/collect.py` gathers them from the CTest log of any port, CI uploads them per configuration, and `tools/evidence/matrix.py` derives the requirement-by-configuration matrix from executed, asserting cases only. diff --git a/configs/footprint-t1.conf b/configs/footprint-t1.conf index b88e122..64684e7 100644 --- a/configs/footprint-t1.conf +++ b/configs/footprint-t1.conf @@ -6,3 +6,4 @@ # CONFIG_EMB_MUTEX is not set # CONFIG_EMB_TRACE is not set # CONFIG_EMB_FAULT_HOOK_RUNTIME is not set +# CONFIG_EMB_EVENT is not set diff --git a/docs/STATUS.md b/docs/STATUS.md index 4b6e714..aa3a02c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -10,7 +10,7 @@ This page is the authoritative inventory of implementation and available evidenc |---|---|---| | Execution and scheduling | UP fixed priority, base bitmap/FIFO scheduler, tiny thread table, scheduler lock, thread lifecycle; idle context unreachable through handles, and slim (control-block prefix only) on tiny without an idle thread (ADR-050) | Common conformance cases; tiny has unique base priorities and restricted scheduling semantics | | Waiting and time | Shared wait protocol, sleep, deadlines, cancellation, suspend overlay, ordered timeouts | Native virtual time exercises periodic and tickless modes; embedded ports currently reject tickless | -| Synchronization | Semaphores, mutexes with inheritance, ceiling, recursion and owner-death policy | Conformance and native differential bridge; inheritance and deadlock detection have a configured depth limit | +| Synchronization | Semaphores, mutexes with inheritance, ceiling, recursion and owner-death policy; event flags with ANY/ALL/CLEAR waits and binding (M2-05) | Conformance and native differential bridge; inheritance and deadlock detection have a configured depth limit | | Notifications | Per-thread bits and one semaphore-to-thread binding, with unbind; a bound thread cannot be destroyed (ADR-046) | Binding takes a one-bit mask; other objects' bindings arrive with those objects; restart generations need the isolated profile | | Objects | Caller-owned generated storage and POINTER handles; per-type active-object lists for semaphores and mutexes (`CONFIG_EMB_OBJ_LIST`) used by init | No INDEXED or TABLE handles, capability rights, partition restart, system freeze, `emb_obj_foreach()` or debug-descriptor export | | Native port | POSIX host-thread gate, deterministic IRQ injection, virtual time | Base/tiny development workflow; no current wall-clock, asynchronous signal stress, simulated isolated profile or Win32 backend | @@ -23,7 +23,7 @@ This page is the authoritative inventory of implementation and available evidenc ## Specified but unimplemented -Event flags, condition variables, barriers, software timers, work queues, message queues, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. +Condition variables, barriers, software timers, work queues, message queues, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. An API or path named in a design or a requirement's verification field may be a planned deliverable. Check this inventory and the filesystem before treating it as available. diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index dfe3b83..1957812 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -75,7 +75,7 @@ The `footprint_t1` reference build was at the R-003 T2 limit of 64 bytes of kern ## M2-05 through M2-09: Primitive acceptance -- [ ] **Event flags:** model overlapping masks and ANY/ALL/CLEAR; test deterministic waiter order, clearing before the next waiter, multi-wake cost, ISR set, timeout/cancel/destroy and binding semantics. Follow SPEC-005 and KRN-SYNC-029. +- [x] **Event flags:** model overlapping masks and ANY/ALL/CLEAR; test deterministic waiter order, clearing before the next waiter, multi-wake cost, ISR set, timeout/cancel/destroy and binding semantics. Follow SPEC-005 and KRN-SYNC-029. - [ ] **Condition variables:** model atomic mutex release/enqueue, no stored signal, and mandatory mutex reacquisition even after timeout/cancel; test broadcasts, priority inheritance, recursive-mutex restrictions and relock response-time implications. Follow SPEC-005 and KRN-SYNC-030. - [ ] **Message queues:** start with the copy-by-value subset; validate item-size/capacity arithmetic and aligned caller storage, direct receiver/slot hand-off, front send, no-wait ISR paths, absolute deadlines, cancellation/destruction and readiness recheck. Bound bytes copied while masked. Follow SPEC-007/KRN-IPC; ownership queues depend on a later pool gate and must not be advertised early. - [ ] **Work queues:** define callback thread/priority and serialized execution; model submit/resubmit states and cancel/flush including callback self-wait misuse, delayed scheduling and teardown. Follow SPEC-006/KRN-WQ; expose only implemented configurations. diff --git a/docs/engineering/resource-bounds.json b/docs/engineering/resource-bounds.json index ade0ac7..041ef6d 100644 --- a/docs/engineering/resource-bounds.json +++ b/docs/engineering/resource-bounds.json @@ -3,103 +3,271 @@ "version": 1, "_comment": "The reviewed inventory of every cost input in the public API's @time annotations (M2-04). tools/evidence/check_bounds.py checks that every public function whose @time is not O(1) maps to entries here and renders resource-bounds.md from this file. Enforcement: 'configuration' (a Kconfig limit), 'admission' (the kernel refuses beyond it), 'structural' (follows from another bound) or 'application' (the kernel does not limit it; the application must). 'masked' says whether the cost runs with interrupts masked.", "bounds": [ - {"id": "waiters", "input": "threads waiting on one wait queue", - "bound": "at most the live threads: a thread waits on at most one queue", - "limit": "base: 254 thread IDs; tiny: CONFIG_EMB_PRIORITY_COUNT - 1 threads", "enforcement": "structural", - "masked": true, - "profiles": "base: PRIORITY_FIFO insert walks from the tail, O(waiters), FIFO O(1); tiny: bitmap set O(1), first waiter O(1) or the raised-thread scan", - "evidence": "SPEC-004 §11; the reference model's queue-order invariant"}, - {"id": "timeouts", "input": "armed timeouts", - "bound": "at most one per thread, so at most the live threads", - "limit": "as for waiters", "enforcement": "structural", "masked": true, - "profiles": "base: doubly linked list, insert O(armed) from the tail, removal O(1); tiny: singly linked list, insert and removal O(armed)", - "evidence": "SPEC-003 §5.1, SPEC-012 §13; the model's one-timeout-per-thread invariant"}, - {"id": "owned", "input": "mutexes one thread owns", - "bound": "not limited by the kernel: at most the mutex objects the application creates", - "limit": "application", "enforcement": "application", "masked": true, - "profiles": "the same singly linked owned list on every profile; acquisition appends, release and recomputation walk it", - "evidence": "SPEC-005 §2.3; A25"}, - {"id": "pi_depth", "input": "inheritance chain hops (PI work)", - "bound": "CONFIG_EMB_PI_MAX_DEPTH hops; each hop recomputes O(owned) and requeues O(waiters)", - "limit": "CONFIG_EMB_PI_MAX_DEPTH (default 8, 1 to 32)", "enforcement": "configuration", "masked": true, - "profiles": "checked builds fault at the limit, release builds stop propagating (no complete inheritance beyond it); deadlock detection inspects the same number of links", - "evidence": "SPEC-005 §2.2, A25; m2-02-contract-review.md; model PI_MAX_DEPTH"}, - {"id": "table_scan", "input": "tiny raised-priority scan", - "bound": "CONFIG_EMB_PRIORITY_COUNT - 1 slots, only while some thread runs above its base priority", - "limit": "CONFIG_EMB_PRIORITY_COUNT, at most the architecture word width", "enforcement": "configuration", - "masked": true, "profiles": "tiny only; base selects through the per-priority bitmap", - "evidence": "ADR-036; kernel/sched/sched.c embk_sched_table_best"}, - {"id": "reschedule", "input": "a scheduling decision", - "bound": "O(1) on base (bitmap and per-priority lists); the table scan on tiny", - "limit": "see table_scan", "enforcement": "structural", "masked": true, - "profiles": "one decision per preemption point; batch wakes reschedule once", - "evidence": "SPEC-002 §6; KRN-WAIT-013"}, - {"id": "batch_wake", "input": "waiters woken at once (destroy with EMB_OBJ_ABORT_WAITERS)", - "bound": "the waiters of the destroyed object", - "limit": "as for waiters", "enforcement": "structural", "masked": true, - "profiles": "one section, one reschedule at the end", - "evidence": "SPEC-004 §6.7; KRN-OBJ-001"}, - {"id": "thread_ids", "input": "diagnostic identity scan at thread init", - "bound": "254 IDs on list profiles, CONFIG_EMB_PRIORITY_COUNT - 1 on tiny", - "limit": "fixed per profile; EMB_ENOMEM beyond it", "enforcement": "admission", "masked": true, - "profiles": "bitmap scan from the lowest ID", - "evidence": "ADR-043; m2-02-thread-identity.md"}, - {"id": "live_threads", "input": "threads on the all-threads list", - "bound": "at most 254 (the ID space)", - "limit": "CONFIG_EMB_THREAD_LIST; the ID admission", "enforcement": "admission", "masked": true, - "profiles": "base only (no list on tiny); walked by thread init and emb_thread_foreach", - "evidence": "SPEC-008 §12; ADR-045"}, - {"id": "active_objects", "input": "active semaphores or mutexes of one type", - "bound": "not limited by the kernel: the objects the application initializes", - "limit": "application", "enforcement": "application", "masked": true, - "profiles": "with CONFIG_EMB_OBJ_LIST only (not on tiny); walked by init", - "evidence": "ADR-045; m2-02-object-lifecycle.md"}, - {"id": "stack", "input": "bytes of one thread's stack", - "bound": "the stack size the application supplied", - "limit": "application", "enforcement": "application", "masked": false, - "profiles": "the high-water scan exists only with CONFIG_EMB_STACK_STATS; init fills the stack in checked and statistics builds", - "evidence": "SPEC-008 §10; KRN-MEM-009"}, - {"id": "init_table", "input": "entries of the initialization table", - "bound": "the EMB_*_DEFINE objects linked into the image", - "limit": "application, fixed at link time", "enforcement": "application", "masked": false, - "profiles": "prekernel only", - "evidence": "KRN-OBJ-009"}, - {"id": "callbacks", "input": "application callbacks the kernel calls (fault hook, trace sink, foreach visitor)", - "bound": "not bounded by the kernel", - "limit": "application", "enforcement": "application", "masked": true, - "profiles": "the fault hook and the trace sink can run with interrupts masked; the foreach visitor runs under the scheduler lock", - "evidence": "API-017; SPEC-015"}, - {"id": "port_action", "input": "the port's fault action (halt or reset)", - "bound": "does not return", - "limit": "port", "enforcement": "structural", "masked": true, - "profiles": "CONFIG_EMB_FAULT_ACTION", - "evidence": "03 §10; SPEC-011 §14"} + { + "id": "waiters", + "input": "threads waiting on one wait queue", + "bound": "at most the live threads: a thread waits on at most one queue", + "limit": "base: 254 thread IDs; tiny: CONFIG_EMB_PRIORITY_COUNT - 1 threads", + "enforcement": "structural", + "masked": true, + "profiles": "base: PRIORITY_FIFO insert walks from the tail, O(waiters), FIFO O(1); tiny: bitmap set O(1), first waiter O(1) or the raised-thread scan", + "evidence": "SPEC-004 \u00a711; the reference model's queue-order invariant" + }, + { + "id": "timeouts", + "input": "armed timeouts", + "bound": "at most one per thread, so at most the live threads", + "limit": "as for waiters", + "enforcement": "structural", + "masked": true, + "profiles": "base: doubly linked list, insert O(armed) from the tail, removal O(1); tiny: singly linked list, insert and removal O(armed)", + "evidence": "SPEC-003 \u00a75.1, SPEC-012 \u00a713; the model's one-timeout-per-thread invariant" + }, + { + "id": "owned", + "input": "mutexes one thread owns", + "bound": "not limited by the kernel: at most the mutex objects the application creates", + "limit": "application", + "enforcement": "application", + "masked": true, + "profiles": "the same singly linked owned list on every profile; acquisition appends, release and recomputation walk it", + "evidence": "SPEC-005 \u00a72.3; A25" + }, + { + "id": "pi_depth", + "input": "inheritance chain hops (PI work)", + "bound": "CONFIG_EMB_PI_MAX_DEPTH hops; each hop recomputes O(owned) and requeues O(waiters)", + "limit": "CONFIG_EMB_PI_MAX_DEPTH (default 8, 1 to 32)", + "enforcement": "configuration", + "masked": true, + "profiles": "checked builds fault at the limit, release builds stop propagating (no complete inheritance beyond it); deadlock detection inspects the same number of links", + "evidence": "SPEC-005 \u00a72.2, A25; m2-02-contract-review.md; model PI_MAX_DEPTH" + }, + { + "id": "table_scan", + "input": "tiny raised-priority scan", + "bound": "CONFIG_EMB_PRIORITY_COUNT - 1 slots, only while some thread runs above its base priority", + "limit": "CONFIG_EMB_PRIORITY_COUNT, at most the architecture word width", + "enforcement": "configuration", + "masked": true, + "profiles": "tiny only; base selects through the per-priority bitmap", + "evidence": "ADR-036; kernel/sched/sched.c embk_sched_table_best" + }, + { + "id": "reschedule", + "input": "a scheduling decision", + "bound": "O(1) on base (bitmap and per-priority lists); the table scan on tiny", + "limit": "see table_scan", + "enforcement": "structural", + "masked": true, + "profiles": "one decision per preemption point; batch wakes reschedule once", + "evidence": "SPEC-002 \u00a76; KRN-WAIT-013" + }, + { + "id": "batch_wake", + "input": "waiters woken at once (destroy with EMB_OBJ_ABORT_WAITERS)", + "bound": "the waiters of the destroyed object", + "limit": "as for waiters", + "enforcement": "structural", + "masked": true, + "profiles": "one section, one reschedule at the end", + "evidence": "SPEC-004 \u00a76.7; KRN-OBJ-001" + }, + { + "id": "thread_ids", + "input": "diagnostic identity scan at thread init", + "bound": "254 IDs on list profiles, CONFIG_EMB_PRIORITY_COUNT - 1 on tiny", + "limit": "fixed per profile; EMB_ENOMEM beyond it", + "enforcement": "admission", + "masked": true, + "profiles": "bitmap scan from the lowest ID", + "evidence": "ADR-043; m2-02-thread-identity.md" + }, + { + "id": "live_threads", + "input": "threads on the all-threads list", + "bound": "at most 254 (the ID space)", + "limit": "CONFIG_EMB_THREAD_LIST; the ID admission", + "enforcement": "admission", + "masked": true, + "profiles": "base only (no list on tiny); walked by thread init and emb_thread_foreach", + "evidence": "SPEC-008 \u00a712; ADR-045" + }, + { + "id": "active_objects", + "input": "active semaphores or mutexes of one type", + "bound": "not limited by the kernel: the objects the application initializes", + "limit": "application", + "enforcement": "application", + "masked": true, + "profiles": "with CONFIG_EMB_OBJ_LIST only (not on tiny); walked by init", + "evidence": "ADR-045; m2-02-object-lifecycle.md" + }, + { + "id": "stack", + "input": "bytes of one thread's stack", + "bound": "the stack size the application supplied", + "limit": "application", + "enforcement": "application", + "masked": false, + "profiles": "the high-water scan exists only with CONFIG_EMB_STACK_STATS; init fills the stack in checked and statistics builds", + "evidence": "SPEC-008 \u00a710; KRN-MEM-009" + }, + { + "id": "init_table", + "input": "entries of the initialization table", + "bound": "the EMB_*_DEFINE objects linked into the image", + "limit": "application, fixed at link time", + "enforcement": "application", + "masked": false, + "profiles": "prekernel only", + "evidence": "KRN-OBJ-009" + }, + { + "id": "callbacks", + "input": "application callbacks the kernel calls (fault hook, trace sink, foreach visitor)", + "bound": "not bounded by the kernel", + "limit": "application", + "enforcement": "application", + "masked": true, + "profiles": "the fault hook and the trace sink can run with interrupts masked; the foreach visitor runs under the scheduler lock", + "evidence": "API-017; SPEC-015" + }, + { + "id": "port_action", + "input": "the port's fault action (halt or reset)", + "bound": "does not return", + "limit": "port", + "enforcement": "structural", + "masked": true, + "profiles": "CONFIG_EMB_FAULT_ACTION", + "evidence": "03 \u00a710; SPEC-011 \u00a714" + } ], "apis": { - "emb_fault_hook": ["callbacks"], - "emb_fault": ["callbacks", "port_action"], - "emb_kernel_init": ["init_table"], - "emb_mutex_init": ["active_objects"], - "emb_mutex_destroy": ["batch_wake", "owned", "pi_depth", "timeouts", "reschedule"], - "emb_mutex_lock": ["owned", "waiters", "pi_depth", "timeouts"], - "emb_mutex_lock_until": ["owned", "waiters", "pi_depth", "timeouts"], - "emb_mutex_unlock": ["owned", "pi_depth", "timeouts", "reschedule"], - "emb_notify_set": ["timeouts", "reschedule"], - "emb_notify_wait": ["timeouts"], - "emb_notify_wait_until": ["timeouts"], - "emb_sched_unlock": ["reschedule"], - "emb_sem_init": ["active_objects"], - "emb_sem_destroy": ["batch_wake", "reschedule"], - "emb_sem_take": ["waiters", "timeouts"], - "emb_sem_take_until": ["waiters", "timeouts"], - "emb_sem_give": ["timeouts", "reschedule"], - "emb_thread_init": ["thread_ids", "live_threads", "stack"], - "emb_thread_exit": ["owned", "pi_depth", "timeouts"], - "emb_thread_join": ["timeouts"], - "emb_thread_set_priority": ["pi_depth", "waiters", "reschedule", "table_scan"], - "emb_thread_sleep": ["timeouts"], - "emb_thread_sleep_until": ["timeouts"], - "emb_thread_stack_info": ["stack"], - "emb_thread_foreach": ["live_threads", "callbacks", "reschedule"] + "emb_fault_hook": [ + "callbacks" + ], + "emb_fault": [ + "callbacks", + "port_action" + ], + "emb_kernel_init": [ + "init_table" + ], + "emb_mutex_init": [ + "active_objects" + ], + "emb_mutex_destroy": [ + "batch_wake", + "owned", + "pi_depth", + "timeouts", + "reschedule" + ], + "emb_mutex_lock": [ + "owned", + "waiters", + "pi_depth", + "timeouts" + ], + "emb_mutex_lock_until": [ + "owned", + "waiters", + "pi_depth", + "timeouts" + ], + "emb_mutex_unlock": [ + "owned", + "pi_depth", + "timeouts", + "reschedule" + ], + "emb_notify_set": [ + "timeouts", + "reschedule" + ], + "emb_notify_wait": [ + "timeouts" + ], + "emb_notify_wait_until": [ + "timeouts" + ], + "emb_sched_unlock": [ + "reschedule" + ], + "emb_sem_init": [ + "active_objects" + ], + "emb_sem_destroy": [ + "batch_wake", + "reschedule" + ], + "emb_sem_take": [ + "waiters", + "timeouts" + ], + "emb_sem_take_until": [ + "waiters", + "timeouts" + ], + "emb_sem_give": [ + "timeouts", + "reschedule" + ], + "emb_thread_init": [ + "thread_ids", + "live_threads", + "stack" + ], + "emb_thread_exit": [ + "owned", + "pi_depth", + "timeouts" + ], + "emb_thread_join": [ + "timeouts" + ], + "emb_thread_set_priority": [ + "pi_depth", + "waiters", + "reschedule", + "table_scan" + ], + "emb_thread_sleep": [ + "timeouts" + ], + "emb_thread_sleep_until": [ + "timeouts" + ], + "emb_thread_stack_info": [ + "stack" + ], + "emb_thread_foreach": [ + "live_threads", + "callbacks", + "reschedule" + ], + "emb_event_init": [ + "active_objects" + ], + "emb_event_destroy": [ + "batch_wake", + "reschedule" + ], + "emb_event_set": [ + "waiters", + "batch_wake", + "timeouts", + "reschedule" + ], + "emb_event_wait": [ + "waiters", + "timeouts" + ], + "emb_event_wait_until": [ + "waiters", + "timeouts" + ] } } diff --git a/docs/engineering/resource-bounds.md b/docs/engineering/resource-bounds.md index 6cc4b65..24715bb 100644 --- a/docs/engineering/resource-bounds.md +++ b/docs/engineering/resource-bounds.md @@ -25,6 +25,11 @@ Every cost input named by a public function's `@time` annotation, with how it is | Function | `@time` | Inputs | |---|---|---| +| `emb_event_destroy()` | O(waiters) with EMB_OBJ_ABORT_WAITERS | batch_wake, reschedule | +| `emb_event_init()` | O(1); O(active event flags) list lookup with CONFIG_EMB_OBJ_LIST | active_objects | +| `emb_event_set()` | O(waiters) in one section, one reschedule | waiters, batch_wake, timeouts, reschedule | +| `emb_event_wait()` | O(1) check; O(waiters + timeouts) finite wait enqueue | waiters, timeouts | +| `emb_event_wait_until()` | O(1) check; O(waiters + timeouts) finite wait enqueue | waiters, timeouts | | `emb_fault()` | O(1) preparation plus trace/hook and the non-returning port action | callbacks, port_action | | `emb_fault_hook()` | the application's | callbacks | | `emb_kernel_init()` | O(init table) | init_table | diff --git a/docs/requirements/KRN-SYNC.md b/docs/requirements/KRN-SYNC.md index bd3b651..566ea41 100644 --- a/docs/requirements/KRN-SYNC.md +++ b/docs/requirements/KRN-SYNC.md @@ -209,8 +209,8 @@ KRN-SYNC-001 to 007 originate in the v0.1 specification (§8.6); KRN-SYNC-008 to ### KRN-SYNC-029 Event flag semantics **Statement.** Each event waiter shall carry its own mask and mode; `set` shall wake every satisfied waiter in queue order, handing it the satisfied bits, and shall apply a waiter's `CLEAR` before evaluating the next waiter; `clear` shall never wake; `set` shall be ISR-safe and O(waiters on that group). **Rationale.** Deterministic consumption of overlapping bits; a single batch wake. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `sync/event_any_all_clear`, `sync/event_overlapping_clear_order`. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-05). +**Verification.** Test: `event_any_all_and_clear`, `event_overlapping_clear_serves_waiters_in_order`, `event_set_wakes_every_satisfied_waiter_in_one_pass`, `event_set_from_isr_wakes_at_exit`, `event_wait_times_out_cancels_and_ignores_clear`; model scenarios of the EVENT group with the invariant that no queued waiter is satisfied by the current bits; differential agreement on the native presets. **Trace.** SPEC-005 §5 ### KRN-SYNC-030 Condition variable semantics @@ -251,8 +251,8 @@ KRN-SYNC-001 to 007 originate in the v0.1 specification (§8.6); KRN-SYNC-008 to ### KRN-SYNC-035 Binding on semaphores and events **Statement.** Semaphores and event groups shall support the notification binding of ADR-027; a count transition from zero to one, or a set that leaves changed nonzero bits after serving direct waiters, shall signal the binding; a transition consumed by a hand-off shall not. **Rationale.** Multi-object wait without poll objects (KRN-NOTIF-004). -**Status.** Accepted 2026-10-07 -**Verification.** Test: `notif/bind_sem`, `notif/bind_event`; model scenario `binding_hook`. +**Status.** Accepted 2026-10-07; implemented for semaphores and event flags. +**Verification.** Test: `notify_semaphore_binding_signals_ready_transition`, `event_binding_fires_after_direct_waiters`; model scenarios `binding_hook`, `event_binding_after_direct_waiters`. **Trace.** SPEC-005 §4.2, §5.2; SPEC-004 §6.3 ### KRN-SYNC-036 Trace and statistics diff --git a/docs/specs/AMENDMENTS-M2.md b/docs/specs/AMENDMENTS-M2.md index 6c3df39..ad6543b 100644 --- a/docs/specs/AMENDMENTS-M2.md +++ b/docs/specs/AMENDMENTS-M2.md @@ -13,3 +13,6 @@ Corrections found while preparing the M2 primitives, where an accepted specifica | A35 | SPEC-006 §7, KRN-NOTIF-011, SPEC-015 | There is no separate `bind_signal` event: a binding's signal is the `notify_set` record with the bound object as its third argument, which a direct set leaves zero. Existing trace decoders and archived captures stay valid. | `kernel/notify/notify.c` | | A36 | SPEC-009 §2, SPEC-006 §3.1 | The binding lives in each ready-state object as an `embk_binding_t`, not in the common object header, so objects that cannot be bound pay nothing for it. | `kernel/include/embk/binding.h` | | A37 | SPEC-006 §3.1 | `unbind` is `prekernel thread`, like `bind`, so setup and teardown code can run before the kernel starts. | `kernel/sync/sem.c` | +| A38 | SPEC-005 §5.2 | An event waiter's mask lives in `wake_data` until a waker replaces it with the handed-off bits, and its mode in the thread's two bit-wait mode bits (ALL, CLEAR), shared with notification waits; the TCB does not grow, which the tiny profile's 32-byte budget requires. | `kernel/sync/event.c`, `kernel/include/embk/kernel.h` | +| A39 | SPEC-005 §5.2, §8 | `emb_event_wait()` with a zero mask or an unknown mode bit is an argument misuse; `set` and `clear` of zero bits are valid no-ops. | `kernel/sync/event.c` | +| A40 | SPEC-005 §5.1, SPEC-001 §6.4 | Event flags get `emb_event_attr_default()` and `emb_event_unbind_notify()`; like semaphores and mutexes they have no `_DEFINE` macro yet, which stays one cross-type change. | `include/emb/event.h` | diff --git a/docs/specs/SPEC-005-synchronization.md b/docs/specs/SPEC-005-synchronization.md index cade1b1..543c14a 100644 --- a/docs/specs/SPEC-005-synchronization.md +++ b/docs/specs/SPEC-005-synchronization.md @@ -233,14 +233,19 @@ emb_status_t emb_event_wait(emb_event_t e, emb_event_bits_t mask, uint8_t mode, emb_timeout_t timeout, emb_event_bits_t *out_satisfied); /* @ctx thread @blocks timeout */ emb_status_t emb_event_wait_until(emb_event_t e, emb_event_bits_t mask, uint8_t mode, emb_instant_t deadline, emb_event_bits_t *out_satisfied); emb_status_t emb_event_bind_notify(emb_event_t e, emb_thread_t t, emb_notify_bits_t bit); +emb_status_t emb_event_unbind_notify(emb_event_t e); +void emb_event_attr_default(emb_event_attr_t *out); /* name NULL, initial 0, flags 0 */ ``` +Implemented in M2 (`CONFIG_EMB_EVENT`, `include/emb/event.h`). The attributes carry three explicit padding bytes; there is no `EMB_EVENT_DEFINE` yet, as for semaphores and mutexes (A40). + ### 5.2 Semantics -- Each waiter records its own `mask` and `mode` in its wait context (the TCB's `wake_data` word and one byte); a waiter is **satisfied** when `(bits & mask) != 0` for `ANY` or `(bits & mask) == mask` for `ALL`. +- Each waiter records its own `mask` and `mode` in its wait context: the mask in the TCB's `wake_data` word until a waker replaces it with the handed-off bits, the mode in the thread's two bit-wait mode bits of `obj.flags`, which a notification wait uses the same way (A38); the control block does not grow. A waiter is **satisfied** when `(bits & mask) != 0` for `ANY` or `(bits & mask) == mask` for `ALL`. - `wait`: evaluate in section 1; if satisfied, apply `CLEAR` (clear `bits & mask`) and return the satisfied bits; else the protocol. On wake, `out_satisfied` holds the bits delivered by hand-off; the waiter does not re-read the group (KRN-WAIT-012). - `set`: `bits |= set_bits`; then walk the queue in order and, for every waiter now satisfied, hand it `bits & mask` and wake it; if that waiter asked for `CLEAR`, clear `bits & mask` **before** evaluating the next waiter. The walk is O(waiters) in one section (the bound is the number of waiters on this group, stated); satisfied waiters are readied as one batch with one reschedule (KRN-WAIT-013). If after the walk `bits` is nonzero and changed, the binding is signalled. - `clear`: `bits &= ~clear_bits`; never wakes anyone. +- Arguments (A39): `wait` with a zero `mask` or a mode bit other than `ALL` and `CLEAR` is `EMB_FAULT_API_ARGUMENT` when checked and `EMB_EINVAL` in release; `set` and `clear` with zero bits succeed and change nothing. Event bits have no kernel-reserved part. - Deterministic consumption: because `CLEAR` is applied in queue order during `set`, two `ALL` waiters on overlapping bits with `CLEAR` see a defined outcome (the higher-priority one consumes; the other keeps waiting), unlike kernels that let woken waiters race for the bits. - Event flags are shared objects; a per-thread notification (SPEC-006) is the cheaper tool when exactly one thread consumes. diff --git a/docs/user/feature-status.md b/docs/user/feature-status.md index e09126f..e76aa06 100644 --- a/docs/user/feature-status.md +++ b/docs/user/feature-status.md @@ -5,10 +5,10 @@ This guide describes the current 0.2 development implementation. The authoritati | Goal | Available now | Still planned or limited | |---|---|---| | Start and coordinate threads | Static storage, priorities, start, sleep, join/detach, cooperative cancellation | Isolated partitions, budgets, restart, SMP | -| Protect or signal work | Mutexes, semaphores, per-thread notifications | Event groups, condition variables, barriers | +| Protect or signal work | Mutexes, semaphores, per-thread notifications, event flags | Condition variables, barriers | | Transfer payloads | Application-owned data with an explicit synchronization protocol | Message queues, pipes, buffer pools | | Run periodic work | A thread using absolute sleep deadlines | Software timers and system work queues | -| Wait on several readiness sources | Semaphore binding and unbind plus notification bits and rechecking; a bound thread cannot be destroyed | General object bindings, restart-safe target lifetime | +| Wait on several readiness sources | Semaphore and event flag bindings, with unbind, plus notification bits and rechecking; a bound thread cannot be destroyed | Message queue bindings, restart-safe target lifetime | | Diagnose failures | State/priority queries, optional stack estimates, synchronous trace and fault hooks | Retained crash formats, CTF rings, full host tooling | | Develop without a board | Native Linux/POSIX virtual time and conformance suite | Physical-board timing, peripheral drivers and HIL evidence | | Deploy a controller platform | Embedded kernel ports and QEMU configurations | Complete board/peripheral/application integration and release qualification | diff --git a/docs/user/synchronization.md b/docs/user/synchronization.md index 7c1f2dc..f59bebd 100644 --- a/docs/user/synchronization.md +++ b/docs/user/synchronization.md @@ -124,3 +124,24 @@ Inheritance changes effective priority, not base priority. It is bounded by `CON Unlock is owner-only. Recursion requires `EMB_MUTEX_RECURSIVE` and a matching unlock for every successful acquisition. For a ceiling mutex, choose a ceiling at least as high as each user's base priority and account for inherited effective priorities in schedulability analysis. A ceiling does not make arbitrary blocking while holding a mutex safe. `EMB_EOWNERDEAD` is special: the caller **owns** the mutex and must repair its protected state. After successful repair, call `emb_mutex_mark_consistent()` and unlock. If repair cannot be performed, use an application recovery/shutdown policy; do not continue with corrupted state. Owner-death behavior depends on configuration, and the current implementation does not provide a persistent “not recoverable” state. The tutorial's workers terminate only after releasing their locks. + +## Event flags + +Event flags (`CONFIG_EMB_EVENT`) hold a word of bits that several threads can wait on. A wait names a mask and `EMB_EVENT_ANY` or `EMB_EVENT_ALL`, optionally with `EMB_EVENT_CLEAR` to consume the bits that satisfied it: + +```c +static emb_event_storage_t status_storage; +static emb_event_t status; +#define LINK_UP ((emb_event_bits_t)0x01u) +#define CONFIGURED ((emb_event_bits_t)0x02u) + +EMB_CHECK(emb_event_init(&status_storage, NULL, &status)); +/* producers, threads or interrupts: */ +EMB_CHECK(emb_event_set(status, LINK_UP)); +/* a consumer that needs both conditions: */ +emb_event_bits_t got; +emb_status_t st = emb_event_wait(status, LINK_UP | CONFIGURED, EMB_EVENT_ALL, + EMB_TIMEOUT(EMB_MS(500)), &got); +``` + +`emb_event_set()` and `emb_event_clear()` are ISR-safe; waits are thread-only. A set serves waiters in priority order (arrival order with `EMB_OBJ_FIFO`). A `CLEAR` waiter removes the bits it was handed before the next waiter is examined, so two clearing waiters on the same bit are served one per set, highest priority first. `emb_event_clear()` never wakes anyone. A woken waiter receives the bits that satisfied it in `got` and does not re-read the word. A bound notification fires only for bits still set after the direct waiters were served; bind and unbind follow the semaphore rules above. diff --git a/include/emb/api_ids.h b/include/emb/api_ids.h index 513ca4f..1e25d76 100644 --- a/include/emb/api_ids.h +++ b/include/emb/api_ids.h @@ -88,5 +88,15 @@ #define EMB_API_ID_VERSION_STRING 0x0052u #define EMB_API_ID_FAULT_HOOK 0x0053u #define EMB_API_ID_SEM_UNBIND_NOTIFY 0x0054u +#define EMB_API_ID_EVENT_ATTR_DEFAULT 0x0055u +#define EMB_API_ID_EVENT_INIT 0x0056u +#define EMB_API_ID_EVENT_DESTROY 0x0057u +#define EMB_API_ID_EVENT_SET 0x0058u +#define EMB_API_ID_EVENT_CLEAR 0x0059u +#define EMB_API_ID_EVENT_GET 0x005Au +#define EMB_API_ID_EVENT_WAIT 0x005Bu +#define EMB_API_ID_EVENT_WAIT_UNTIL 0x005Cu +#define EMB_API_ID_EVENT_BIND_NOTIFY 0x005Du +#define EMB_API_ID_EVENT_UNBIND_NOTIFY 0x005Eu #endif /* EMB_API_IDS_H */ diff --git a/include/emb/emb.h b/include/emb/emb.h index 557644b..c04882d 100644 --- a/include/emb/emb.h +++ b/include/emb/emb.h @@ -7,6 +7,9 @@ #include #include #include +#if CONFIG_EMB_EVENT +#include +#endif #include #include #include diff --git a/include/emb/event.h b/include/emb/event.h new file mode 100644 index 0000000..8ebc011 --- /dev/null +++ b/include/emb/event.h @@ -0,0 +1,243 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* Event flags (SPEC-005 §5): a word of bits, ANY or ALL waits, ISR-safe set and clear. */ +#ifndef EMB_EVENT_H +#define EMB_EVENT_H + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +EMB_DECLARE_HANDLE(emb_event_t); + +/* Defined by the generated . */ +typedef union emb_event_storage emb_event_storage_t; + +#if CONFIG_EMB_EVENT_BITS == 8 +typedef uint8_t emb_event_bits_t; +#elif CONFIG_EMB_EVENT_BITS == 16 +typedef uint16_t emb_event_bits_t; +#else +typedef uint32_t emb_event_bits_t; +#endif + +/* Wait modes: ANY or ALL of the mask, optionally clearing the satisfying bits. */ +#define EMB_EVENT_ANY ((uint8_t)0x00u) +#define EMB_EVENT_ALL ((uint8_t)0x01u) +#define EMB_EVENT_CLEAR ((uint8_t)0x02u) + +typedef struct emb_event_attr { + const char *name; + emb_event_bits_t initial; /* default 0 */ + uint8_t flags; /* EMB_OBJ_ABORT_WAITERS | EMB_OBJ_FIFO */ + uint8_t reserved_[3]; /* explicit padding */ +} emb_event_attr_t; + +/** + * emb_event_attr_default() - Fill event flag attributes with defaults. + * @out_attr: Receives name NULL, initial 0 and flags 0; NULL is a no-op. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_EVENT + * @req API-020 KRN-SYNC-029 + * @since 0.3 + * @stable no + */ +void emb_event_attr_default(emb_event_attr_t *out_attr); + +/** + * emb_event_init() - Construct event flags in caller-provided storage. + * @storage: Aligned caller-owned storage, never initialized or destroyed; retain it until + * successful destruction. + * @attr: Copied attributes; NULL selects defaults. Keep name storage alive. + * @out_event: Required output handle; cleared before argument validation. + * + * @ctx prekernel thread + * @blocks no + * @time O(1); O(active event flags) list lookup with CONFIG_EMB_OBJ_LIST + * @owns borrows:storage + * @config CONFIG_EMB_EVENT + * @req API-021 KRN-SYNC-029 KRN-OBJ-003 KRN-OBJ-007 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EINVAL for bad storage or output; EMB_EBUSY when @storage still + * holds event flags that were not destroyed (misuse; exact with CONFIG_EMB_OBJ_LIST). + */ +emb_status_t emb_event_init(emb_event_storage_t *storage, const emb_event_attr_t *attr, + emb_event_t *out_event); + +/** + * emb_event_destroy() - End the event flags (KRN-OBJ-001). + * @event: Initialized event flags; no waiters unless ABORT_WAITERS is set. + * + * @ctx thread + * @blocks no + * @time O(waiters) with EMB_OBJ_ABORT_WAITERS + * @owns gives:storage + * @config CONFIG_EMB_EVENT + * @req KRN-OBJ-001 KRN-NOTIF-014 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EBUSY with waiters and no ABORT_WAITERS (misuse). A binding is + * removed and its target released; the target's bits are not touched. + */ +emb_status_t emb_event_destroy(emb_event_t event); + +/** + * emb_event_set() - OR @bits in, then wake every waiter the new value satisfies. + * @event: Initialized event flags. + * @bits: Bits to set; 0 changes nothing. + * + * @ctx thread isr + * @blocks no + * @time O(waiters) in one section, one reschedule + * @owns none + * @config CONFIG_EMB_EVENT + * @req KRN-SYNC-029 KRN-SYNC-005 KRN-SYNC-027 KRN-WAIT-013 + * @since 0.3 + * @stable no + * + * Waiters are examined in queue order (priority, or arrival with EMB_OBJ_FIFO). Each + * satisfied waiter is handed the satisfying bits; a CLEAR waiter clears them before the + * next waiter is examined, so overlapping clearing waiters are served deterministically. + * A bound thread is notified when bits remain set and differ from before the call. + * + * Return: EMB_OK; EMB_EINVAL for a bad handle. + */ +emb_status_t emb_event_set(emb_event_t event, emb_event_bits_t bits); + +/** + * emb_event_clear() - Clear @bits; never wakes anyone. + * @event: Initialized event flags. + * @bits: Bits to clear; 0 changes nothing. + * + * @ctx thread isr + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_EVENT + * @req KRN-SYNC-029 KRN-SYNC-027 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EINVAL for a bad handle. + */ +emb_status_t emb_event_clear(emb_event_t event, emb_event_bits_t bits); + +/** + * emb_event_get() - Snapshot of the bits; 0 for a bad handle. + * @event: Initialized event flags. + * + * @ctx thread isr + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_EVENT + * @req KRN-SYNC-029 + * @since 0.3 + * @stable no + */ +emb_event_bits_t emb_event_get(emb_event_t event); + +/** + * emb_event_wait() - Wait until ANY or ALL of @mask is set. + * @event: Initialized event flags. + * @mask: Nonzero bits of interest. + * @mode: EMB_EVENT_ANY or EMB_EVENT_ALL, optionally | EMB_EVENT_CLEAR. + * @timeout: EMB_NO_WAIT, EMB_WAIT_FOREVER or EMB_TIMEOUT(duration). + * @out_satisfied: Receives the satisfying bits on EMB_OK; may be NULL. + * + * @ctx thread + * @blocks timeout + * @time O(1) check; O(waiters + timeouts) finite wait enqueue + * @owns none + * @config CONFIG_EMB_EVENT + * @req KRN-SYNC-029 KRN-WAIT-012 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_ETIMEDOUT; EMB_ECANCELED; EMB_EDESTROYED; EMB_EINVAL for a zero + * mask, an unknown mode bit or a bad handle; EMB_EPERM outside thread context. + * A woken waiter receives the bits that satisfied it; the word is not re-read. + */ +emb_status_t emb_event_wait(emb_event_t event, emb_event_bits_t mask, uint8_t mode, + emb_timeout_t timeout, emb_event_bits_t *out_satisfied); + +/** + * emb_event_wait_until() - Wait for ANY or ALL of @mask with an absolute deadline. + * @event: Initialized event flags. + * @mask: Nonzero bits of interest. + * @mode: EMB_EVENT_ANY or EMB_EVENT_ALL, optionally | EMB_EVENT_CLEAR. + * @deadline: Kernel-clock deadline; a past deadline checks without waiting. + * @out_satisfied: Receives the satisfying bits on EMB_OK; may be NULL. + * + * @ctx thread + * @blocks timeout + * @time O(1) check; O(waiters + timeouts) finite wait enqueue + * @owns none + * @config CONFIG_EMB_EVENT + * @req API-024 KRN-TIM-011 KRN-SYNC-029 + * @since 0.3 + * @stable no + * + * Return: the statuses of emb_event_wait(). + */ +emb_status_t emb_event_wait_until(emb_event_t event, emb_event_bits_t mask, uint8_t mode, + emb_instant_t deadline, emb_event_bits_t *out_satisfied); + +#if CONFIG_EMB_NOTIFY +/** + * emb_event_bind_notify() - Set @bit in @thread when a set leaves bits changed and nonzero. + * @event: Initialized event flags with no binding. + * @thread: Initialized, not terminated, application thread; it cannot be destroyed while + * bound (ADR-046). + * @bit: One application-bit mask, e.g. EMB_NOTIFY_BIT(0). + * + * @ctx prekernel thread + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_EVENT CONFIG_EMB_NOTIFY + * @req KRN-SYNC-035 KRN-NOTIF-004 KRN-NOTIF-005 KRN-NOTIF-014 + * @since 0.3 + * @stable no + * + * Return: the statuses of emb_sem_bind_notify(). Direct waiters are served first; the + * binding fires only for bits still set after them. + */ +emb_status_t emb_event_bind_notify(emb_event_t event, emb_thread_t thread, emb_notify_bits_t bit); + +/** + * emb_event_unbind_notify() - Remove the event flags' notification binding. + * @event: Initialized event flags. + * + * @ctx prekernel thread + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_EVENT CONFIG_EMB_NOTIFY + * @req KRN-NOTIF-013 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_ENOENT when not bound; EMB_EINVAL for a bad handle. + */ +emb_status_t emb_event_unbind_notify(emb_event_t event); +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* EMB_EVENT_H */ diff --git a/include/emb/trace.h b/include/emb/trace.h index fcb619c..16cfeae 100644 --- a/include/emb/trace.h +++ b/include/emb/trace.h @@ -36,7 +36,7 @@ enum emb_trace_id { EMB_TRACE_MUTEX_UNLOCK = 17, /* a0 = index, a1 = mutex address, a2 = new owner index */ EMB_TRACE_SEM_TAKE = 18, /* a0 = index, a1 = sem address, a2 = status */ EMB_TRACE_SEM_GIVE = 19, /* a0 = index or 0xFF, a1 = sem address, a2 = count after */ - EMB_TRACE_NOTIFY_SET = 20, /* a0 = target index, a1 = bits */ + EMB_TRACE_NOTIFY_SET = 20, /* a0 = target index, a1 = bits, a2 = bound object or 0 (A35) */ EMB_TRACE_NOTIFY_WAIT = 21, /* a0 = index, a1 = status, a2 = bits */ EMB_TRACE_SUSPEND = 22, /* a0 = index */ EMB_TRACE_RESUME = 23, /* a0 = index */ @@ -45,8 +45,10 @@ enum emb_trace_id { EMB_TRACE_PI_DEPTH = 26, /* a0 = index, a1 = depth reached */ EMB_TRACE_IDLE_ENTER = 27, EMB_TRACE_IDLE_EXIT = 28, - EMB_TRACE_TICK = 29, /* a0 = ticks low word */ - EMB_TRACE_SLEEP = 30 /* a0 = index, a1 = deadline low word */ + EMB_TRACE_TICK = 29, /* a0 = ticks low word */ + EMB_TRACE_SLEEP = 30, /* a0 = index, a1 = deadline low word */ + EMB_TRACE_EVENT_SET = 31, /* a0 = waiters woken, a1 = event address, a2 = bits after */ + EMB_TRACE_EVENT_WAIT = 32 /* a0 = index, a1 = event address, a2 = status */ }; typedef struct emb_trace_event { diff --git a/kernel/CMakeLists.txt b/kernel/CMakeLists.txt index c43dbfa..9518ab0 100644 --- a/kernel/CMakeLists.txt +++ b/kernel/CMakeLists.txt @@ -24,6 +24,9 @@ endif() if(CONFIG_EMB_MUTEX) list(APPEND EMB_KERNEL_SOURCES sync/mutex.c) endif() +if(CONFIG_EMB_EVENT) + list(APPEND EMB_KERNEL_SOURCES sync/event.c) +endif() add_library(emb_kernel STATIC ${EMB_KERNEL_SOURCES}) emb_use_kernel_compiler(emb_kernel) diff --git a/kernel/Kconfig b/kernel/Kconfig index 55e9575..33ae0ba 100644 --- a/kernel/Kconfig +++ b/kernel/Kconfig @@ -152,6 +152,17 @@ config EMB_SEM_COUNT_BITS default 32 range 8 32 +config EMB_EVENT + bool "Event flags (SPEC-005 §5)" + default y + +config EMB_EVENT_BITS + int "Event flag word width: 8, 16, or 32" + depends on EMB_EVENT + default 8 if EMB_PROFILE_TINY + default 32 + range 8 32 + config EMB_MUTEX bool "Mutexes (SPEC-005 §3)" default y diff --git a/kernel/include/embk/kernel.h b/kernel/include/embk/kernel.h index 550c184..bac2726 100644 --- a/kernel/include/embk/kernel.h +++ b/kernel/include/embk/kernel.h @@ -39,6 +39,7 @@ #define EMBK_OBJ_THREAD ((uint8_t)0x51u) #define EMBK_OBJ_SEM ((uint8_t)0x52u) #define EMBK_OBJ_MUTEX ((uint8_t)0x53u) +#define EMBK_OBJ_EVENT ((uint8_t)0x54u) #define EMBK_OBJ_UNINIT ((uint8_t)0u) #define EMBK_OBJ_ACTIVE ((uint8_t)1u) @@ -120,8 +121,9 @@ typedef uint8_t embk_wait_result_t; #define EMBK_THREAD_KERNEL ((uint8_t)0x40u) /* idle or work-queue thread */ #define EMBK_THREAD_CRITICAL ((uint8_t)0x80u) -/* A thread's type-specific obj.flags bits (SPEC-009 §2): the notification mode of the - * current wait (bits 0 to 1) and the wake result (bits 2 to 4). */ +/* A thread's type-specific obj.flags bits (SPEC-009 §2): the mode of the current bit wait, + * notification or event flags (bits 0 to 1, ANY/ALL and CLEAR), and the wake result + * (bits 2 to 4). An event flag wait keeps its mask in wake_data until woken (A38). */ #define EMBK_TOBJ_NOTIFY_MASK ((uint8_t)0x03u) #define EMBK_TOBJ_RESULT_SHIFT 2u #define EMBK_TOBJ_RESULT_MASK ((uint8_t)0x1Cu) diff --git a/kernel/include/embk/sync.h b/kernel/include/embk/sync.h index c8c3877..6158554 100644 --- a/kernel/include/embk/sync.h +++ b/kernel/include/embk/sync.h @@ -4,6 +4,7 @@ #ifndef EMBK_SYNC_H #define EMBK_SYNC_H +#include #include #include @@ -28,6 +29,24 @@ extern embk_list_t embk_sem_all; /* every active semaphore (KRN-OBJ-011); lock: #endif #endif +#if CONFIG_EMB_EVENT +typedef struct embk_event { + embk_obj_t obj; /* obj.flags: EMB_OBJ_ABORT_WAITERS | EMB_OBJ_FIFO */ + embk_wait_queue_t waiters; + emb_event_bits_t bits; +#if CONFIG_EMB_NOTIFY + embk_binding_t binding; /* signaled when a set leaves bits changed and nonzero */ +#endif +#if CONFIG_EMB_OBJ_LIST + embk_list_node_t all_node; /* in embk_event_all from init to destroy */ +#endif +} embk_event_t; + +#if CONFIG_EMB_OBJ_LIST +extern embk_list_t embk_event_all; /* every active event flags object; lock: scheduler domain */ +#endif +#endif + #if CONFIG_EMB_MUTEX #define EMBK_MUTEX_INCONSISTENT ((uint8_t)0x02u) /* obj.flags bit: owner died (SPEC-005 §3.6) */ #define EMBK_MUTEX_OWNERDEAD_DATA ((uintptr_t)1u) /* hand-off word flag */ diff --git a/kernel/include/embk/wait.h b/kernel/include/embk/wait.h index f404137..a1e53c0 100644 --- a/kernel/include/embk/wait.h +++ b/kernel/include/embk/wait.h @@ -27,6 +27,20 @@ embk_wait_result_t embk_block_on(embk_wait_queue_t *q, uint8_t reason, emb_tick_ /* Wakers, object lock held. */ embk_thread_t *embk_wait_first(const embk_wait_queue_t *q); + +/* Walk @q's waiters in wake order while waking some of them (event flags, SPEC-005 §5.2): + * the cursor is taken before the waiter is returned, so waking it is safe. Waking other + * waiters during the walk is not. Object lock held. */ +typedef struct embk_wait_iter { +#if CONFIG_EMB_SCHED_TABLE + embk_word_t rest; /* waiters not yet returned */ +#else + embk_list_node_t *next; /* the node after the one returned, or NULL */ +#endif +} embk_wait_iter_t; + +void embk_wait_iter_begin(const embk_wait_queue_t *q, embk_wait_iter_t *it); +embk_thread_t *embk_wait_iter_next(const embk_wait_queue_t *q, embk_wait_iter_t *it); bool embk_wait_wake(embk_thread_t *t, embk_wait_result_t result, uintptr_t data); unsigned embk_wait_wake_all(embk_wait_queue_t *q, embk_wait_result_t result, uintptr_t data); void embk_wait_requeue(embk_thread_t *t); diff --git a/kernel/object/storage_probe.c b/kernel/object/storage_probe.c index 6ebbc15..e6c067e 100644 --- a/kernel/object/storage_probe.c +++ b/kernel/object/storage_probe.c @@ -29,4 +29,7 @@ void embk_storage_probe(void) #if CONFIG_EMB_MUTEX PROBE(mutex, embk_mutex_t); #endif +#if CONFIG_EMB_EVENT + PROBE(event, embk_event_t); +#endif } diff --git a/kernel/sync/event.c b/kernel/sync/event.c new file mode 100644 index 0000000..1cc443f --- /dev/null +++ b/kernel/sync/event.c @@ -0,0 +1,285 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* + * Event flags (SPEC-005 §5): a word of bits that threads wait on with ANY or ALL of a + * mask. A waiter keeps its mask in wake_data and its mode in the thread's bit-wait mode + * (A38), so the control block does not grow. Set walks the waiters in queue order in one + * section, hands each satisfied one its bits and clears them first for a CLEAR waiter. + */ +#include +#include + +#include +#include +#include +#include +#include +#include + +#if CONFIG_EMB_EVENT + +EMB_STATIC_ASSERT(sizeof(embk_event_t) <= EMB_EVENT_STORAGE_SIZE, "generated storage too small"); + +#if CONFIG_EMB_OBJ_LIST +embk_list_t embk_event_all = EMBK_LIST_INITIALIZER(embk_event_all); /* usable before kernel init */ +#endif + +static EMB_INLINE embk_event_t *event_from_handle(emb_event_t h) +{ + return (embk_event_t *)embk_obj_from_raw(h.raw, EMBK_OBJ_EVENT, offsetof(embk_event_t, obj)); +} + +/* The bits a waiter with @mask and @mode is satisfied by, or 0. */ +static emb_event_bits_t satisfying(emb_event_bits_t bits, emb_event_bits_t mask, uint8_t mode) +{ + emb_event_bits_t got = bits & mask; + if ((mode & EMB_EVENT_ALL) != 0u) { + return (got == mask) ? got : 0u; + } + return got; +} + +void emb_event_attr_default(emb_event_attr_t *out_attr) +{ + if (out_attr == NULL) { + return; + } + out_attr->name = NULL; + out_attr->initial = 0u; + out_attr->flags = 0u; + out_attr->reserved_[0] = 0u; + out_attr->reserved_[1] = 0u; + out_attr->reserved_[2] = 0u; +} + +emb_status_t emb_event_init(emb_event_storage_t *storage, const emb_event_attr_t *attr, + emb_event_t *out_event) +{ + embk_event_t *e; + emb_event_attr_t def; +#if CONFIG_EMB_OBJ_LIST + emb_irq_key_t key; +#endif + if (out_event != NULL) { + out_event->raw = 0u; + } + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_EVENT_INIT); + EMBK_API_REQUIRE(EMB_API_ID_EVENT_INIT, storage != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, + 1u); + EMBK_API_REQUIRE(EMB_API_ID_EVENT_INIT, out_event != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, + 3u); + EMBK_API_REQUIRE(EMB_API_ID_EVENT_INIT, + ((uintptr_t)storage % (uintptr_t)EMB_EVENT_STORAGE_ALIGN) == 0u, + EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + if (attr == NULL) { + emb_event_attr_default(&def); + attr = &def; + } + e = (embk_event_t *)(void *)storage; +#if CONFIG_EMB_OBJ_LIST + /* storage is reused only through destroy (SPEC-009 §6, KRN-OBJ-007) */ + key = embk_lock_sched(); + if (embk_list_contains(&embk_event_all, &e->all_node)) { + embk_unlock_sched(key); + EMBK_API_MISUSE(EMB_API_ID_EVENT_INIT, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); + } +#endif + embk_obj_init(&e->obj, EMBK_OBJ_EVENT, attr->flags & (EMB_OBJ_ABORT_WAITERS | EMB_OBJ_FIFO), + attr->name); + embk_wait_queue_init(&e->waiters, ((attr->flags & EMB_OBJ_FIFO) != 0u) ? EMBK_WAIT_FIFO : 0u); + e->bits = attr->initial; +#if CONFIG_EMB_NOTIFY + embk_binding_init(&e->binding); +#endif +#if CONFIG_EMB_OBJ_LIST + embk_list_append(&embk_event_all, &e->all_node); + embk_unlock_sched(key); +#endif + out_event->raw = embk_obj_to_raw(e); + return EMB_OK; +} + +emb_status_t emb_event_destroy(emb_event_t event) +{ + embk_event_t *e = event_from_handle(event); + emb_irq_key_t key; + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_EVENT_DESTROY); + EMBK_API_REQUIRE(EMB_API_ID_EVENT_DESTROY, e != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + key = embk_lock_object(e); + if (!embk_wait_queue_is_empty(&e->waiters)) { + if ((e->obj.flags & EMB_OBJ_ABORT_WAITERS) == 0u) { + embk_unlock_object(e, key); + EMBK_API_MISUSE(EMB_API_ID_EVENT_DESTROY, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); + } + (void)embk_wait_flush(&e->waiters, EMBK_WAKE_DESTROYED); + } +#if CONFIG_EMB_NOTIFY + (void)embk_binding_unbind(&e->binding); /* releases the target; its bits stay */ +#endif +#if CONFIG_EMB_OBJ_LIST + embk_list_remove(&e->all_node); +#endif + e->obj.state = EMBK_OBJ_UNINIT; + embk_sched_reschedule_if_needed(); + embk_unlock_object(e, key); + return EMB_OK; +} + +emb_status_t emb_event_set(emb_event_t event, emb_event_bits_t bits) +{ + embk_event_t *e = event_from_handle(event); + embk_wait_iter_t it; + embk_thread_t *w; + emb_irq_key_t key; + emb_event_bits_t before; + unsigned woken = 0u; + EMBK_API_REQUIRE(EMB_API_ID_EVENT_SET, e != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + key = embk_lock_object(e); + before = e->bits; + e->bits |= bits; + embk_wait_iter_begin(&e->waiters, &it); + /* bounded by the waiters; one section, one reschedule (KRN-WAIT-013) */ + while ((w = embk_wait_iter_next(&e->waiters, &it)) != NULL) { + uint8_t mode = embk_thread_notify_mode(w); + emb_event_bits_t got = satisfying(e->bits, (emb_event_bits_t)w->wake_data, mode); + if (got != 0u) { + if ((mode & EMB_EVENT_CLEAR) != 0u) { + e->bits &= (emb_event_bits_t)~got; /* before the next waiter is examined */ + } + (void)embk_wait_wake(w, EMBK_WAKE_SATISFIED, (uintptr_t)got); + woken++; + } + } +#if CONFIG_EMB_NOTIFY + if (e->bits != 0u && e->bits != before) { + embk_binding_signal(&e->binding, e); /* what direct waiters left (KRN-NOTIF-005) */ + } +#endif + EMBK_TRACE(EMB_TRACE_EVENT_SET, woken, e, e->bits); + (void)woken; /* only the trace reports it */ + embk_sched_reschedule_if_needed(); + embk_unlock_object(e, key); + return EMB_OK; +} + +emb_status_t emb_event_clear(emb_event_t event, emb_event_bits_t bits) +{ + embk_event_t *e = event_from_handle(event); + emb_irq_key_t key; + EMBK_API_REQUIRE(EMB_API_ID_EVENT_CLEAR, e != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + key = embk_lock_object(e); + e->bits &= (emb_event_bits_t)~bits; + embk_unlock_object(e, key); + return EMB_OK; +} + +emb_event_bits_t emb_event_get(emb_event_t event) +{ + const embk_event_t *e = event_from_handle(event); + return (e == NULL) ? 0u : e->bits; +} + +static emb_status_t wait_common(uint16_t api_id, embk_event_t *e, emb_event_bits_t mask, + uint8_t mode, emb_tick_t deadline, bool nowait, + emb_event_bits_t *out_satisfied) +{ + embk_thread_t *self; + emb_irq_key_t key; + embk_wait_result_t r; + uintptr_t data; + emb_event_bits_t got; + EMBK_API_REQUIRE_THREAD(api_id); + if (e == NULL) { + EMBK_TRACE(EMB_TRACE_EVENT_WAIT, embk_thread_index(embk_cpu.current), 0u, EMB_EINVAL); + EMBK_API_MISUSE(api_id, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + } + EMBK_API_REQUIRE(api_id, mask != 0u, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); + EMBK_API_REQUIRE(api_id, (mode & (uint8_t) ~(EMB_EVENT_ALL | EMB_EVENT_CLEAR)) == 0u, + EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 3u); + EMBK_API_REQUIRE_CAN_BLOCK(api_id, nowait ? 0u : 1u); + self = embk_cpu.current; + key = embk_lock_object(e); + if (embk_thread_take_cancel_locked(self)) { + EMBK_TRACE(EMB_TRACE_EVENT_WAIT, embk_thread_index(self), e, EMB_ECANCELED); + embk_unlock_object(e, key); + return EMB_ECANCELED; + } + got = satisfying(e->bits, mask, mode); + if (got != 0u) { + if ((mode & EMB_EVENT_CLEAR) != 0u) { + e->bits &= (emb_event_bits_t)~got; + } + EMBK_TRACE(EMB_TRACE_EVENT_WAIT, embk_thread_index(self), e, EMB_OK); + embk_unlock_object(e, key); + if (out_satisfied != NULL) { + *out_satisfied = got; + } + return EMB_OK; + } + if (nowait) { + EMBK_TRACE(EMB_TRACE_EVENT_WAIT, embk_thread_index(self), e, EMB_ETIMEDOUT); + embk_unlock_object(e, key); + return EMB_ETIMEDOUT; + } + self->wake_data = (uintptr_t)mask; /* the wait's arguments until a waker replaces them */ + embk_thread_set_notify_mode(self, mode); + r = embk_block_on(&e->waiters, EMB_WAIT_REASON_OBJECT, deadline, key, &data); + EMBK_TRACE(EMB_TRACE_EVENT_WAIT, embk_thread_index(self), e, embk_wait_result_status(r)); + if (r == EMBK_WAKE_SATISFIED && out_satisfied != NULL) { + *out_satisfied = (emb_event_bits_t)data; + } + return embk_wait_result_status(r); +} + +emb_status_t emb_event_wait(emb_event_t event, emb_event_bits_t mask, uint8_t mode, + emb_timeout_t timeout, emb_event_bits_t *out_satisfied) +{ + return wait_common(EMB_API_ID_EVENT_WAIT, event_from_handle(event), mask, mode, + embk_deadline_from_timeout(timeout), EMB_TIMEOUT_IS_NO_WAIT(timeout), + out_satisfied); +} + +emb_status_t emb_event_wait_until(emb_event_t event, emb_event_bits_t mask, uint8_t mode, + emb_instant_t deadline, emb_event_bits_t *out_satisfied) +{ + bool nowait = deadline.ticks != EMB_TICK_MAX && embk_deadline_passed(deadline.ticks); + return wait_common(EMB_API_ID_EVENT_WAIT_UNTIL, event_from_handle(event), mask, mode, + deadline.ticks, nowait, out_satisfied); +} + +#if CONFIG_EMB_NOTIFY +emb_status_t emb_event_bind_notify(emb_event_t event, emb_thread_t thread, emb_notify_bits_t bit) +{ + embk_event_t *e = event_from_handle(event); + embk_thread_t *t = embk_thread_from_handle(thread); + emb_irq_key_t key; + emb_status_t st; + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_EVENT_BIND_NOTIFY); + EMBK_API_REQUIRE(EMB_API_ID_EVENT_BIND_NOTIFY, e != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + EMBK_API_REQUIRE(EMB_API_ID_EVENT_BIND_NOTIFY, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 2u); + EMBK_API_REQUIRE(EMB_API_ID_EVENT_BIND_NOTIFY, + bit != 0u && (bit & (emb_notify_bits_t)(bit - 1u)) == 0u && + (bit & (emb_notify_bits_t)~EMB_NOTIFY_APP_MASK) == 0u, + EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 3u); + key = embk_lock_object(e); + st = embk_binding_bind(&e->binding, t, bit); + embk_unlock_object(e, key); + return st; +} + +emb_status_t emb_event_unbind_notify(emb_event_t event) +{ + embk_event_t *e = event_from_handle(event); + emb_irq_key_t key; + bool bound; + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_EVENT_UNBIND_NOTIFY); + EMBK_API_REQUIRE(EMB_API_ID_EVENT_UNBIND_NOTIFY, e != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, + 1u); + key = embk_lock_object(e); + bound = embk_binding_unbind(&e->binding); + embk_unlock_object(e, key); + return bound ? EMB_OK : EMB_ENOENT; +} +#endif + +#endif /* CONFIG_EMB_EVENT */ diff --git a/kernel/wait/wait.c b/kernel/wait/wait.c index 9e04025..f1e676f 100644 --- a/kernel/wait/wait.c +++ b/kernel/wait/wait.c @@ -79,6 +79,34 @@ embk_thread_t *embk_wait_first(const embk_wait_queue_t *q) #endif } +void embk_wait_iter_begin(const embk_wait_queue_t *q, embk_wait_iter_t *it) +{ +#if CONFIG_EMB_SCHED_TABLE + it->rest = q->set; +#else + it->next = embk_list_first(&q->waiters); +#endif +} + +embk_thread_t *embk_wait_iter_next(const embk_wait_queue_t *q, embk_wait_iter_t *it) +{ + embk_thread_t *t; +#if CONFIG_EMB_SCHED_TABLE + (void)q; + t = embk_sched_table_best(it->rest); /* the order embk_wait_first() uses */ + if (t != NULL) { + it->rest &= (embk_word_t)~EMBK_WORD_BIT(t->base_prio); + } +#else + if (it->next == NULL) { + return NULL; + } + t = EMB_CONTAINER_OF(it->next, embk_thread_t, wait_node); + it->next = embk_list_next(&q->waiters, it->next); +#endif + return t; +} + /* ---- blocking (§5.1) ------------------------------------------------------------ */ void embk_wait_prepare(embk_wait_queue_t *q, uint8_t reason) diff --git a/tests/conformance/CMakeLists.txt b/tests/conformance/CMakeLists.txt index 566bb8b..9558d66 100644 --- a/tests/conformance/CMakeLists.txt +++ b/tests/conformance/CMakeLists.txt @@ -20,6 +20,9 @@ endif() if(CONFIG_EMB_MUTEX) emb_add_test(test_mutex test_mutex.c) endif() +if(CONFIG_EMB_EVENT) + emb_add_test(test_event test_event.c) +endif() emb_add_test(test_first_execution test_first_execution.c) if(CONFIG_EMB_ARCH_HAS_FPU_LAZY) emb_add_test(test_fpu test_fpu.c) diff --git a/tests/conformance/test_event.c b/tests/conformance/test_event.c new file mode 100644 index 0000000..18590ac --- /dev/null +++ b/tests/conformance/test_event.c @@ -0,0 +1,189 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* Event flag conformance (SPEC-005 §5; KRN-SYNC-029, KRN-SYNC-035, KRN-WAIT-013). */ +#include + +#define E0 ((emb_event_bits_t)0x01u) +#define E1 ((emb_event_bits_t)0x02u) +#define E2 ((emb_event_bits_t)0x04u) +#define IRQ_E 2u /* software interrupt slot */ + +static emb_event_storage_t storage; +static emb_event_t ev; + +static void make(emb_event_bits_t initial, uint8_t flags) +{ + emb_event_attr_t attr; + emb_event_attr_default(&attr); + attr.initial = initial; + attr.flags = flags; + EMB_ASSERT_OK(emb_event_init(&storage, &attr, &ev)); +} + +/* Waits for the mask and mode packed in arg in 12 bits, so that a 16-bit uintptr_t (AVR) + * holds them: mask in bits 0 to 3, mode in bits 4 to 7, id in bits 8 to 11. */ +static void waiter(void *arg) +{ + uintptr_t a = (uintptr_t)arg; + emb_event_bits_t got = 0u; + emb_status_t st = emb_event_wait(ev, (emb_event_bits_t)(a & 0x0Fu), (uint8_t)((a >> 4) & 0x0Fu), + EMB_TIMEOUT(EMB_TICKS(10)), &got); + int id = (int)((a >> 8) & 0x0Fu); + emb_test_mark(st == EMB_OK ? id * 10 + (int)got : (st == EMB_ETIMEDOUT ? id + 500 : id + 700)); +} + +#define WAIT_ARG(mask, mode, id) ((void *)(uintptr_t)((mask) | ((mode) << 4) | ((id) << 8))) + +EMB_TEST(event_any_all_and_clear) +{ + emb_event_bits_t got = 0u; + EMB_TEST_REQ("KRN-SYNC-029"); + make(E0, 0u); + EMB_ASSERT_EQ(emb_event_get(ev), E0); + EMB_ASSERT_OK(emb_event_wait(ev, E0 | E1, EMB_EVENT_ANY, EMB_NO_WAIT, &got)); + EMB_ASSERT_EQ(got, E0); + EMB_ASSERT_STATUS(emb_event_wait(ev, E0 | E1, EMB_EVENT_ALL, EMB_NO_WAIT, &got), EMB_ETIMEDOUT); + EMB_ASSERT_OK(emb_event_set(ev, E1)); + EMB_ASSERT_OK(emb_event_wait(ev, E0 | E1, EMB_EVENT_ALL | EMB_EVENT_CLEAR, EMB_NO_WAIT, &got)); + EMB_ASSERT_EQ(got, E0 | E1); + EMB_ASSERT_EQ(emb_event_get(ev), 0u); /* CLEAR consumed exactly the satisfying bits */ + EMB_ASSERT_OK(emb_event_set(ev, E2)); + EMB_ASSERT_OK(emb_event_clear(ev, E2)); + EMB_ASSERT_OK(emb_event_set(ev, 0u)); /* no-ops */ + EMB_ASSERT_OK(emb_event_clear(ev, 0u)); + EMB_ASSERT_EQ(emb_event_get(ev), 0u); + EMB_ASSERT_OK(emb_event_destroy(ev)); +} + +EMB_TEST(event_overlapping_clear_serves_waiters_in_order) +{ + EMB_TEST_REQ("KRN-SYNC-029", "KRN-WAIT-001"); + make(0u, 0u); + (void)emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, WAIT_ARG(E0, EMB_EVENT_CLEAR, 3u)); + (void)emb_test_thread(EMB_TEST_NAME("w4"), 4u, 0u, waiter, WAIT_ARG(E0, EMB_EVENT_CLEAR, 4u)); + EMB_ASSERT_OK(emb_event_set(ev, E0)); /* the higher waiter consumes the bit */ + EMB_ASSERT_MARKS(41); + EMB_ASSERT_EQ(emb_event_get(ev), 0u); + EMB_ASSERT_OK(emb_event_set(ev, E0)); /* the next set serves the other */ + EMB_ASSERT_MARKS(41, 31); + EMB_ASSERT_OK(emb_event_destroy(ev)); +} + +EMB_TEST(event_set_wakes_every_satisfied_waiter_in_one_pass) +{ + EMB_TEST_REQ("KRN-SYNC-029", "KRN-WAIT-013"); + make(0u, 0u); + (void)emb_test_thread(EMB_TEST_NAME("w2"), 2u, 0u, waiter, WAIT_ARG(E0, EMB_EVENT_ANY, 2u)); + (void)emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, + WAIT_ARG(E0 | E1, EMB_EVENT_ALL, 3u)); + (void)emb_test_thread(EMB_TEST_NAME("w4"), 4u, 0u, waiter, WAIT_ARG(E1, EMB_EVENT_ANY, 4u)); + EMB_ASSERT_OK(emb_event_set(ev, E0 | E1)); /* all three, by priority, bits kept */ + EMB_ASSERT_MARKS(42, 33, 21); + EMB_ASSERT_EQ(emb_event_get(ev), E0 | E1); + EMB_ASSERT_OK(emb_event_destroy(ev)); +} + +EMB_TEST_ISR(isr_set) +{ + (void)emb_isr_arg_; + emb_test_mark(100); + EMB_ASSERT_OK(emb_event_set(ev, E2)); +} + +EMB_TEST(event_set_from_isr_wakes_at_exit) +{ + EMB_TEST_REQ("KRN-SYNC-029", "KRN-SYNC-027"); + make(0u, 0u); + emb_test_irq_connect(IRQ_E, isr_set, NULL); + (void)emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, WAIT_ARG(E2, EMB_EVENT_CLEAR, 3u)); + emb_test_irq_raise(IRQ_E); + emb_test_mark(1); + EMB_ASSERT_MARKS(100, 34, 1); + EMB_ASSERT_OK(emb_event_destroy(ev)); +} + +EMB_TEST(event_wait_times_out_cancels_and_ignores_clear) +{ + emb_thread_t h; + uint32_t t0 = EMB_TEST_NOW(); + EMB_TEST_REQ("KRN-SYNC-029", "KRN-WAIT-004", "KRN-WAIT-017"); + make(0u, 0u); + (void)emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, WAIT_ARG(E0, EMB_EVENT_ANY, 3u)); + EMB_ASSERT_OK(emb_event_clear(ev, E0)); /* clear never wakes */ + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(20))); + EMB_ASSERT_MARKS(503); + EMB_ASSERT_ELAPSED(t0, 20u); + h = emb_test_thread(EMB_TEST_NAME("w4"), 4u, 0u, waiter, WAIT_ARG(E0, EMB_EVENT_ANY, 4u)); + EMB_ASSERT_OK(emb_thread_cancel(h)); + EMB_ASSERT_MARKS(503, 704); /* EMB_ECANCELED */ + EMB_ASSERT_OK(emb_event_destroy(ev)); +} + +EMB_TEST(event_destroy_aborts_waiters_when_allowed) +{ + EMB_TEST_REQ("KRN-OBJ-001", "KRN-WAIT-018"); + make(0u, EMB_OBJ_ABORT_WAITERS); + (void)emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, WAIT_ARG(E0, EMB_EVENT_ANY, 3u)); + EMB_ASSERT_OK(emb_event_destroy(ev)); + EMB_ASSERT_MARKS(703); /* EMB_EDESTROYED, reported as id + 700 */ +} + +EMB_TEST(event_destroy_with_waiters_is_misuse) +{ + EMB_TEST_REQ("KRN-OBJ-001", "API-013"); + make(0u, 0u); + (void)emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, WAIT_ARG(E0, EMB_EVENT_ANY, 3u)); + EMB_ASSERT_MISUSE(emb_event_destroy(ev), EMB_EBUSY); + EMB_ASSERT_OK(emb_event_set(ev, E0)); + EMB_ASSERT_MARKS(31); + EMB_ASSERT_OK(emb_event_destroy(ev)); +} + +EMB_TEST(event_wait_validates_arguments) +{ + emb_event_t other; + EMB_TEST_REQ("KRN-SYNC-029", "API-015"); + make(0u, 0u); + EMB_ASSERT_MISUSE(emb_event_wait(ev, 0u, EMB_EVENT_ANY, EMB_NO_WAIT, NULL), EMB_EINVAL); + EMB_ASSERT_MISUSE(emb_event_wait(ev, E0, 0x10u, EMB_NO_WAIT, NULL), EMB_EINVAL); + EMB_ASSERT_MISUSE(emb_event_init(NULL, NULL, &other), EMB_EINVAL); /* clears other only */ + EMB_ASSERT_OK(emb_event_destroy(ev)); +} + +#if CONFIG_EMB_NOTIFY +EMB_TEST(event_binding_fires_after_direct_waiters) +{ + emb_notify_bits_t got = 0u; + EMB_TEST_REQ("KRN-SYNC-035", "KRN-NOTIF-004", "KRN-NOTIF-005", "KRN-NOTIF-013"); + make(0u, 0u); + EMB_ASSERT_OK(emb_event_bind_notify(ev, emb_thread_self(), EMB_NOTIFY_BIT(0))); + EMB_ASSERT_STATUS(emb_event_bind_notify(ev, emb_thread_self(), EMB_NOTIFY_BIT(1)), EMB_EEXIST); + (void)emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, WAIT_ARG(E0, EMB_EVENT_CLEAR, 3u)); + EMB_ASSERT_OK(emb_event_set(ev, E0)); /* consumed by the waiter: no bits left, no signal */ + EMB_ASSERT_MARKS(31); + EMB_ASSERT_STATUS(emb_notify_wait(EMB_NOTIFY_BIT(0), EMB_NOTIFY_ANY, EMB_NO_WAIT, &got), + EMB_ETIMEDOUT); + EMB_ASSERT_OK(emb_event_set(ev, E1)); /* left set: the binding fires */ + EMB_ASSERT_OK( + emb_notify_wait(EMB_NOTIFY_BIT(0), EMB_NOTIFY_ANY | EMB_NOTIFY_CLEAR, EMB_NO_WAIT, &got)); + EMB_ASSERT_OK(emb_event_set(ev, E1)); /* unchanged: no new signal */ + EMB_ASSERT_STATUS(emb_notify_wait(EMB_NOTIFY_BIT(0), EMB_NOTIFY_ANY, EMB_NO_WAIT, &got), + EMB_ETIMEDOUT); + EMB_ASSERT_OK(emb_event_unbind_notify(ev)); + EMB_ASSERT_STATUS(emb_event_unbind_notify(ev), EMB_ENOENT); + EMB_ASSERT_OK(emb_event_destroy(ev)); +} +#endif + +#if CONFIG_EMB_OBJ_LIST +EMB_TEST(event_storage_reuse_requires_destroy) +{ + emb_event_t other; + EMB_TEST_REQ("KRN-OBJ-007", "KRN-OBJ-011"); + make(0u, 0u); + EMB_ASSERT_MISUSE(emb_event_init(&storage, NULL, &other), EMB_EBUSY); + EMB_ASSERT_OK(emb_event_destroy(ev)); + make(0u, 0u); + EMB_ASSERT_OK(emb_event_destroy(ev)); +} +#endif diff --git a/tests/differential/diff_runner.c b/tests/differential/diff_runner.c index 0960cb9..4aa2e3c 100644 --- a/tests/differential/diff_runner.c +++ b/tests/differential/diff_runner.c @@ -12,13 +12,15 @@ * T=[,inactive]:;... a thread and its program * S=[,fifo][,bind=.] a semaphore * M=,, a mutex (protocol 0 inherit, 1 ceiling, 2 none) - * I:;... an ISR body (give, resume, nset, start only) + * E=[,fifo][,bind=.] event flags + * I:;... an ISR body (give, resume, nset, start, eset, eclear) * Operations (t: -1 forever, 0 no wait, n ticks): * take s t | give s | lock m t | unlock m | nset tid bits | nwait mask all clear t | * sleep t | yield | setprio tid prio | suspend tid | resume tid | cancel tid | destroy s | * slock | sunlock | exit code | start tid | join tid t | bind s tid bit | unbind s | tdestroy tid - * A schedule entry fires right after the n-th trace event (or in idle, if that event - * never comes): "isr@n" raises ISR i, "tick@n" advances virtual time by one tick. + * | eset e bits | eclear e bits | ewait e mask all clear t | edestroy e A schedule entry fires + * right after the n-th trace event (or in idle, if that event never comes): "isr@n" raises ISR + * i, "tick@n" advances virtual time by one tick. */ #include @@ -37,6 +39,7 @@ #define MAX_OPS 16u #define MAX_SEMS 4u #define MAX_MUTEXES 4u +#define MAX_EVFLAGS 4u #define MAX_ISRS 4u #define MAX_EVENTS 16384u #define MAX_SCHED 64u @@ -69,7 +72,11 @@ enum op_kind { OP_JOIN, OP_BIND, OP_UNBIND, - OP_TDESTROY + OP_TDESTROY, + OP_ESET, + OP_ECLEAR, + OP_EWAIT, + OP_EDESTROY }; struct op { @@ -108,6 +115,18 @@ struct sem_desc { emb_sem_t handle; }; +struct evflag_desc { + long eid; + long bits; + bool fifo; + long bind_tid; + long bind_bit; +#if CONFIG_EMB_EVENT + emb_event_storage_t storage; + emb_event_t handle; +#endif +}; + struct mutex_desc { long mid; long protocol; @@ -136,6 +155,8 @@ static struct thread_desc threads[MAX_THREADS]; static unsigned thread_count; static struct sem_desc sems[MAX_SEMS]; static unsigned sem_count; +static struct evflag_desc evflags[MAX_EVFLAGS]; +static unsigned evflag_count; static struct mutex_desc mutexes[MAX_MUTEXES]; static unsigned mutex_count; static struct program isrs[MAX_ISRS]; @@ -201,9 +222,9 @@ static void parse_op(char *text, struct op *op) { char *p = text; const char *name = cut(&p, ' '); - const char *args[4] = {NULL, NULL, NULL, NULL}; + const char *args[5] = {NULL, NULL, NULL, NULL, NULL}; unsigned n = 0u; - while (p != NULL && n < 4u) { + while (p != NULL && n < 5u) { args[n++] = cut(&p, ' '); } op->a = 0; @@ -280,6 +301,19 @@ static void parse_op(char *text, struct op *op) } else if (strcmp(name, "tdestroy") == 0) { op->kind = OP_TDESTROY; op->a = num(args[0]); + } else if (strcmp(name, "eset") == 0 || strcmp(name, "eclear") == 0) { + op->kind = (name[1] == 's') ? OP_ESET : OP_ECLEAR; + op->a = num(args[0]); + op->b = num(args[1]); + } else if (strcmp(name, "ewait") == 0) { + op->kind = OP_EWAIT; + op->a = num(args[0]); + op->b = num(args[1]); + op->c = num(args[2]) | (num(args[3]) << 1); /* all | clear << 1 */ + op->d = num(args[4]); + } else if (strcmp(name, "edestroy") == 0) { + op->kind = OP_EDESTROY; + op->a = num(args[0]); } else { die("unknown operation"); } @@ -339,6 +373,29 @@ static void parse_section(char *sec) die("bad semaphore attribute"); } } + } else if (kind == 'E') { + struct evflag_desc *e; + char *attrs; + if (evflag_count >= MAX_EVFLAGS) { + die("too many event flags"); + } + e = &evflags[evflag_count++]; + e->eid = num(cut(&p, '=')); + attrs = p; + e->bits = num(cut(&attrs, ',')); + e->bind_tid = -1; + while (attrs != NULL && *attrs != '\0') { + char *a = cut(&attrs, ','); + if (strcmp(a, "fifo") == 0) { + e->fifo = true; + } else if (strncmp(a, "bind=", 5) == 0) { + char *b = a + 5; + e->bind_tid = num(cut(&b, '.')); + e->bind_bit = num(b); + } else { + die("bad event flag attribute"); + } + } } else if (kind == 'M') { struct mutex_desc *m; char *attrs; @@ -421,6 +478,20 @@ static emb_sem_t sem_by_sid(long sid) return sems[0].handle; } +#if CONFIG_EMB_EVENT +static emb_event_t evflag_by_eid(long eid) +{ + unsigned i; + for (i = 0u; i < evflag_count; i++) { + if (evflags[i].eid == eid) { + return evflags[i].handle; + } + } + die("unknown event flags id"); + return evflags[0].handle; +} +#endif + static emb_mutex_t mutex_by_mid(long mid) { unsigned i; @@ -610,6 +681,33 @@ static void run_ops(const struct program *prog, struct thread_desc *self) } break; } +#if CONFIG_EMB_EVENT + case OP_ESET: + st = emb_event_set(evflag_by_eid(op->a), (emb_event_bits_t)op->b); + break; + case OP_ECLEAR: + st = emb_event_clear(evflag_by_eid(op->a), (emb_event_bits_t)op->b); + break; + case OP_EWAIT: { + emb_event_bits_t got = 0u; + uint8_t mode = (uint8_t)(((op->c & 1) != 0 ? EMB_EVENT_ALL : EMB_EVENT_ANY) | + ((op->c & 2) != 0 ? EMB_EVENT_CLEAR : 0u)); + st = emb_event_wait(evflag_by_eid(op->a), (emb_event_bits_t)op->b, mode, + timeout_of(op->d), &got); + data = got; + break; + } + case OP_EDESTROY: + st = emb_event_destroy(evflag_by_eid(op->a)); + break; +#else + case OP_ESET: + case OP_ECLEAR: + case OP_EWAIT: + case OP_EDESTROY: + die("event flags are not configured"); + break; +#endif default: die("bad operation kind"); break; @@ -644,14 +742,15 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) return; } dumped = 1u; - (void)printf("config profile=%s owner_death=%s checked=%d tickless=%d idle_index=254\n", - CONFIG_EMB_PROFILE, + (void)printf( + "config profile=%s owner_death=%s checked=%d tickless=%d idle_index=254 events=%d\n", + CONFIG_EMB_PROFILE, #if CONFIG_EMB_MUTEX_OWNER_DEATH_FAULT && CONFIG_EMB_CHECKED - "fault", + "fault", #else - "release", + "release", #endif - (int)CONFIG_EMB_CHECKED, (int)CONFIG_EMB_TICKLESS); + (int)CONFIG_EMB_CHECKED, (int)CONFIG_EMB_TICKLESS, (int)CONFIG_EMB_EVENT); n = event_count; for (i = 0u; i < n; i++) { (void)printf("ev %u %u %lu %lu %lu %lu\n", (unsigned)events[i].id, @@ -678,6 +777,12 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) for (i = 0u; i < sem_count; i++) { (void)printf("sem %ld %lu\n", sems[i].sid, (unsigned long)emb_sem_count(sems[i].handle)); } +#if CONFIG_EMB_EVENT + for (i = 0u; i < evflag_count; i++) { + (void)printf("evflag %ld %lu\n", evflags[i].eid, + (unsigned long)emb_event_get(evflags[i].handle)); + } +#endif (void)printf("end %s %lu %lu\n", end, e0, e1); (void)fflush(stdout); } @@ -737,6 +842,19 @@ static void construct(void) attr.flags = (uint8_t)(EMB_OBJ_ABORT_WAITERS | (sems[i].fifo ? EMB_OBJ_FIFO : 0u)); EMB_CHECK(emb_sem_init(&sems[i].storage, &attr, &sems[i].handle)); } +#if CONFIG_EMB_EVENT + for (i = 0u; i < evflag_count; i++) { + emb_event_attr_t attr; + emb_event_attr_default(&attr); + attr.initial = (emb_event_bits_t)evflags[i].bits; + attr.flags = (uint8_t)(EMB_OBJ_ABORT_WAITERS | (evflags[i].fifo ? EMB_OBJ_FIFO : 0u)); + EMB_CHECK(emb_event_init(&evflags[i].storage, &attr, &evflags[i].handle)); + } +#else + if (evflag_count != 0u) { + die("event flags are not configured"); + } +#endif for (i = 0u; i < mutex_count; i++) { emb_mutex_attr_t attr; emb_mutex_attr_default(&attr); @@ -761,6 +879,15 @@ static void construct(void) EMB_NOTIFY_BIT(sems[i].bind_bit))); } } +#if CONFIG_EMB_EVENT + for (i = 0u; i < evflag_count; i++) { + if (evflags[i].bind_tid >= 0) { + EMB_CHECK(emb_event_bind_notify(evflags[i].handle, + thread_by_tid(evflags[i].bind_tid)->handle, + EMB_NOTIFY_BIT(evflags[i].bind_bit))); + } + } +#endif for (i = 0u; i < isr_count; i++) { EMB_CHECK(emb_irq_connect((emb_irq_t)(ISR_IRQ_BASE + i), isr_body, (void *)(uintptr_t)i)); } diff --git a/tools/fault/api-ids.json b/tools/fault/api-ids.json index aa50e9c..3230734 100644 --- a/tools/fault/api-ids.json +++ b/tools/fault/api-ids.json @@ -505,6 +505,66 @@ "name": "emb_sem_unbind_notify", "header": "sem.h", "state": "active" + }, + { + "id": 85, + "name": "emb_event_attr_default", + "header": "event.h", + "state": "active" + }, + { + "id": 86, + "name": "emb_event_init", + "header": "event.h", + "state": "active" + }, + { + "id": 87, + "name": "emb_event_destroy", + "header": "event.h", + "state": "active" + }, + { + "id": 88, + "name": "emb_event_set", + "header": "event.h", + "state": "active" + }, + { + "id": 89, + "name": "emb_event_clear", + "header": "event.h", + "state": "active" + }, + { + "id": 90, + "name": "emb_event_get", + "header": "event.h", + "state": "active" + }, + { + "id": 91, + "name": "emb_event_wait", + "header": "event.h", + "state": "active" + }, + { + "id": 92, + "name": "emb_event_wait_until", + "header": "event.h", + "state": "active" + }, + { + "id": 93, + "name": "emb_event_bind_notify", + "header": "event.h", + "state": "active" + }, + { + "id": 94, + "name": "emb_event_unbind_notify", + "header": "event.h", + "state": "active" } ] } diff --git a/tools/fault/api-registry.json b/tools/fault/api-registry.json index 3d9e448..7e309b4 100644 --- a/tools/fault/api-registry.json +++ b/tools/fault/api-registry.json @@ -504,6 +504,66 @@ "name": "emb_sem_unbind_notify", "header": "sem.h", "state": "active" + }, + { + "id": 85, + "name": "emb_event_attr_default", + "header": "event.h", + "state": "active" + }, + { + "id": 86, + "name": "emb_event_init", + "header": "event.h", + "state": "active" + }, + { + "id": 87, + "name": "emb_event_destroy", + "header": "event.h", + "state": "active" + }, + { + "id": 88, + "name": "emb_event_set", + "header": "event.h", + "state": "active" + }, + { + "id": 89, + "name": "emb_event_clear", + "header": "event.h", + "state": "active" + }, + { + "id": 90, + "name": "emb_event_get", + "header": "event.h", + "state": "active" + }, + { + "id": 91, + "name": "emb_event_wait", + "header": "event.h", + "state": "active" + }, + { + "id": 92, + "name": "emb_event_wait_until", + "header": "event.h", + "state": "active" + }, + { + "id": 93, + "name": "emb_event_bind_notify", + "header": "event.h", + "state": "active" + }, + { + "id": 94, + "name": "emb_event_unbind_notify", + "header": "event.h", + "state": "active" } ] } diff --git a/tools/model/bridge.py b/tools/model/bridge.py index fd37e68..d7e0abb 100644 --- a/tools/model/bridge.py +++ b/tools/model/bridge.py @@ -28,7 +28,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from embmodel import (BindNotify, Cancel, Destroy, Exit, Give, Join, Lock, ModelError, # noqa: E402 +from embmodel import (BindNotify, Cancel, Destroy, EventClear, EventDestroy, EventSet, # noqa: E402 + EventWait, Exit, Give, Join, Lock, ModelError, NONWAIT_RESULT_OPS, NotifySet, NotifyWait, Policy, Reason, Result, Resume, Scenario, SchedLock, SchedUnlock, SetPrio, Sleep, Start, Suspend, Take, ThreadDestroy, UnbindNotify, Unlock, Yield, build, FOREVER, NO_WAIT) @@ -40,6 +41,7 @@ TR_BLOCK, TR_WAKE, TR_TIMEOUT_ARM, TR_TIMEOUT_EXPIRE = 6, 7, 8, 9 TR_MUTEX_LOCK, TR_MUTEX_UNLOCK, TR_SEM_TAKE, TR_SEM_GIVE = 16, 17, 18, 19 TR_NOTIFY_SET, TR_NOTIFY_WAIT, TR_FAULT, TR_TICK = 20, 21, 25, 29 +TR_EVENT_SET, TR_EVENT_WAIT = 31, 32 EV_ISR_MARK = 1000 # the runner's own record: ISR body i starts EV_INDEX_MARK = 1001 # the runner's record: thread tid now has index i IDLE_INDEX = 0xFE @@ -133,6 +135,14 @@ def encode_op(op) -> str: return f"unbind {op.sem}" if isinstance(op, ThreadDestroy): return f"tdestroy {op.tid}" + if isinstance(op, EventSet): + return f"eset {op.event} {op.bits}" + if isinstance(op, EventClear): + return f"eclear {op.event} {op.bits}" + if isinstance(op, EventWait): + return f"ewait {op.event} {op.mask} {int(op.all)} {int(op.clear)} {_t(op.timeout)}" + if isinstance(op, EventDestroy): + return f"edestroy {op.event}" raise ValueError(f"unknown op {op!r}") @@ -149,6 +159,14 @@ def encode_scenario(sc: Scenario) -> str: tid, bit = cfg["binding"] attrs.append(f"bind={tid}.{bit}") parts.append(f"S{sid}=" + ",".join(attrs)) + for eid, cfg in sc.events.items(): + attrs = [str(cfg.get("bits", 0))] + if cfg.get("policy") == Policy.FIFO: + attrs.append("fifo") + if cfg.get("binding") is not None: + tid, bit = cfg["binding"] + attrs.append(f"bind={tid}.{bit}") + parts.append(f"E{eid}=" + ",".join(attrs)) for mid, cfg in sc.mutexes.items(): proto = int(cfg.get("protocol", 0)) parts.append(f"M{mid}={proto},{cfg.get('ceiling', 0)},{int(bool(cfg.get('recursive', False)))}") @@ -167,6 +185,7 @@ def __init__(self): self.results = {} # tid -> {op_index: (status, data)} self.states = {} # tid -> (state, reason) self.sems = {} # sid -> count + self.evflags = {} # eid -> bits self.end = None # ("done",) or ("fault", class, code) self.events_lost = 0 self.exit_code = 0 @@ -230,6 +249,8 @@ def parse_run(stdout: str, stderr: str = "", exit_code: int = 0) -> Run: r.states[int(f[1])] = (int(f[2]), int(f[3])) elif f[0] == "sem": r.sems[int(f[1])] = int(f[2]) + elif f[0] == "evflag": + r.evflags[int(f[1])] = int(f[2]) elif f[0] == "events_lost": r.events_lost = int(f[1]) elif f[0] == "end": @@ -270,7 +291,7 @@ def thr(idx): out.append(("wait_begin", thr(a0), KREASON_TO_REASON[a1], ("q", a2))) elif eid == TR_WAKE: out.append(("wake", thr(a0), KWAKE_TO_RESULT[a1])) - elif eid in (TR_SEM_TAKE, TR_NOTIFY_WAIT, TR_MUTEX_LOCK): + elif eid in (TR_SEM_TAKE, TR_NOTIFY_WAIT, TR_MUTEX_LOCK, TR_EVENT_WAIT): st = a1 if eid == TR_NOTIFY_WAIT else a2 st = st - (1 << 64) if st >= (1 << 63) else st # printed as unsigned long res = Result.SATISFIED if st == -7 else STATUS_TO_RESULT.get(st) @@ -541,7 +562,7 @@ def finish(self, k, path): kres_mapped = Result.TIMEOUT # a completed sleep is EMB_OK in the kernel if kres_mapped != result: raise Divergence(f"thread {tid} op {op_index} {op!r}: model {result.name}, kernel status {kstatus}") - if isinstance(op, (NotifyWait, Join, Lock)) and result == Result.SATISFIED and kdata != data: + if isinstance(op, (NotifyWait, Join, Lock, EventWait)) and result == Result.SATISFIED and kdata != data: raise Divergence(f"thread {tid} op {op_index} {op!r}: model data {data}, kernel data {kdata}") kstate = r.states.get(tid) if t.destroyed: @@ -559,6 +580,9 @@ def finish(self, k, path): for sid, s in k.sems.items(): if sid in r.sems and not s.destroyed and r.sems[sid] != s.count: raise Divergence(f"semaphore {sid}: kernel count {r.sems[sid]}, model {s.count}") + for eid, e in k.events.items(): + if eid in r.evflags and not e.destroyed and r.evflags[eid] != e.bits: + raise Divergence(f"event flags {eid}: kernel bits {r.evflags[eid]:#x}, model {e.bits:#x}") if k.enabled() and any(a[0] == "run" for a in k.enabled()): raise Divergence("the kernel finished while the model still has a thread to run") @@ -611,10 +635,12 @@ def applicable(sc: Scenario, config: dict) -> tuple: profile = config.get("profile", "base") bitmap = any(cfg.get("policy") == Policy.BITMAP for cfg in sc.sems.values()) prios = [p for p, _ in sc.threads.values()] + if sc.events and config.get("events", "0") != "1": + return False, "event flags are not configured in this build" if profile == "tiny": if len(set(prios)) != len(prios): return False, "shared priorities need the base profile" - if any(cfg.get("policy") == Policy.FIFO for cfg in sc.sems.values()): + if any(cfg.get("policy") == Policy.FIFO for cfg in list(sc.sems.values()) + list(sc.events.values())): return False, "FIFO queues need the base profile" elif bitmap: return False, "BITMAP queues need the tiny profile" @@ -625,7 +651,8 @@ def adapt(sc: Scenario, config: dict) -> Scenario: """The model's static configuration follows the runner's build.""" if config.get("profile") == "tiny": sems = {sid: dict(cfg, policy=Policy.BITMAP) for sid, cfg in sc.sems.items()} - sc = replace(sc, sems=sems) + events = {eid: dict(cfg, policy=Policy.BITMAP) for eid, cfg in sc.events.items()} + sc = replace(sc, sems=sems, events=events) # Historical native captures predate the clock-mode field and used tickless builds. return replace(sc, owner_death=config.get("owner_death", "release"), tickless=config.get("tickless", "1") == "1") diff --git a/tools/model/embmodel/__init__.py b/tools/model/embmodel/__init__.py index 2366040..c4e4740 100644 --- a/tools/model/embmodel/__init__.py +++ b/tools/model/embmodel/__init__.py @@ -9,7 +9,8 @@ granularity and checks the invariants of SPEC-004 §5.4 after every step. """ from .kernel import ( # noqa: F401 - BindNotify, Cancel, Destroy, Exit, Give, Join, NONWAIT_RESULT_OPS, ThreadDestroy, UnbindNotify, Kernel, Lock, ModelError, Mutex, NotifySet, NotifyWait, Policy, + BindNotify, Cancel, Destroy, EventClear, EventDestroy, EventSet, EventWait, Exit, Give, Join, + NONWAIT_RESULT_OPS, ThreadDestroy, UnbindNotify, Kernel, Lock, ModelError, Mutex, NotifySet, NotifyWait, Policy, Protocol, Reason, Result, Resume, Start, SchedLock, SchedUnlock, Scenario, SetPrio, Sleep, Suspend, Take, Thread, Unlock, WaitState, Yield, FOREVER, NO_WAIT, OWNERDEAD_FLAG, PI_MAX_DEPTH, build, ) diff --git a/tools/model/embmodel/kernel.py b/tools/model/embmodel/kernel.py index d633be6..9a32081 100644 --- a/tools/model/embmodel/kernel.py +++ b/tools/model/embmodel/kernel.py @@ -63,6 +63,7 @@ class Protocol(IntEnum): # SPEC-005 §3 CANCELABLE = frozenset({Reason.SLEEP, Reason.OBJECT, Reason.JOIN, Reason.NOTIFY}) # KRN-WAIT-015 PI_MAX_DEPTH = 8 # CONFIG_EMB_PI_MAX_DEPTH default MUTEX_QID_BASE = 100 # queue ids: semaphores 0..99, mutexes 100.., thread pseudo-queues negative +EVENT_QID_BASE = 200 # event flags 200.. class ModelError(AssertionError): @@ -197,7 +198,45 @@ class ThreadDestroy: NONWAIT_RESULT_OPS = (BindNotify, UnbindNotify, ThreadDestroy) # record a result although they never wait -ISR_SAFE_OPS = (Give, Resume, NotifySet, Start) # SPEC-001 §5.2: signal-type operations are `thread isr` + +@dataclass(frozen=True) +class EventSet: + """emb_event_set(event, bits): thread or ISR context (SPEC-005 §5.2).""" + event: int + bits: int + + +@dataclass(frozen=True) +class EventClear: + """emb_event_clear(event, bits): never wakes anyone.""" + event: int + bits: int + + +@dataclass(frozen=True) +class EventWait: + """emb_event_wait(event, mask, mode, timeout): ANY unless all=True; clear consumes the satisfying bits.""" + event: int + mask: int + all: bool = False + clear: bool = False + timeout: Optional[int] = FOREVER + + +@dataclass(frozen=True) +class EventDestroy: + """emb_event_destroy(event) with EMB_OBJ_ABORT_WAITERS: waiters leave with DESTROYED.""" + event: int + + +def event_satisfying(bits: int, mask: int, all_: bool) -> int: + got = bits & mask + if all_: + return got if got == mask else 0 + return got + + +ISR_SAFE_OPS = (Give, Resume, NotifySet, Start, EventSet, EventClear) # SPEC-001 §5.2: signal-type operations are `thread isr` # ---------------------------------------------------------------------------------------- @@ -272,6 +311,18 @@ def key(self): self.handed, self.immediate, self.binding) +@dataclass +class Event: + eid: int + bits: int + qid: int + destroyed: bool = False + binding: Optional[tuple] = None + + def key(self): + return (self.eid, self.bits, self.qid, self.destroyed, self.binding) + + @dataclass class Mutex: mid: int @@ -294,6 +345,7 @@ class Scenario: threads: dict # tid -> (prio, program tuple) sems: dict = field(default_factory=dict) # sid -> dict(count=, policy=, binding=) mutexes: dict = field(default_factory=dict) # mid -> dict(protocol=, ceiling=, recursive=) + events: dict = field(default_factory=dict) # eid -> dict(bits=, policy=, binding=) isrs: tuple = () owner_death: str = "release" # CONFIG_EMB_MUTEX_OWNER_DEATH: "release" | "fault" inactive: tuple = () # tids created INACTIVE (must be started by another thread or an ISR) @@ -315,6 +367,10 @@ def build(sc: Scenario) -> "Kernel": k.queues[sid] = WaitQueue(qid=sid, policy=cfg.get("policy", Policy.PRIORITY_FIFO)) k.sems[sid] = Semaphore(sid=sid, count=cfg.get("count", 0), initial=cfg.get("count", 0), qid=sid, binding=cfg.get("binding")) + for eid, cfg in sc.events.items(): + qid = EVENT_QID_BASE + eid + k.queues[qid] = WaitQueue(qid=qid, policy=cfg.get("policy", Policy.PRIORITY_FIFO)) + k.events[eid] = Event(eid=eid, bits=cfg.get("bits", 0), qid=qid, binding=cfg.get("binding")) for mid, cfg in sc.mutexes.items(): qid = MUTEX_QID_BASE + mid k.queues[qid] = WaitQueue(qid=qid, policy=Policy.BITMAP if bitmap else Policy.PRIORITY_FIFO) @@ -349,6 +405,7 @@ class Kernel: queues: dict = field(default_factory=dict) sems: dict = field(default_factory=dict) mutexes: dict = field(default_factory=dict) + events: dict = field(default_factory=dict) qid_mutex: dict = field(default_factory=dict) # static map, not part of the key ready: dict = field(default_factory=dict) current: Optional[int] = None @@ -371,6 +428,7 @@ def key(self): tuple(q.key() for _, q in sorted(self.queues.items())), tuple(s.key() for _, s in sorted(self.sems.items())), tuple(m.key() for _, m in sorted(self.mutexes.items())), + tuple(e.key() for _, e in sorted(self.events.items())), tuple((p, tuple(v)) for p, v in sorted(self.ready.items()) if v), self.current, self.now, self.irq_nesting, self.sched_lock_depth, self.reschedule_pending, tuple(self.timeouts), self.timer_pending, self.dequeued, @@ -384,6 +442,7 @@ def clone(self) -> "Kernel": k.queues = {i: WaitQueue(q.qid, q.policy, list(q.waiters)) for i, q in self.queues.items()} k.sems = {i: copy.copy(s) for i, s in self.sems.items()} k.mutexes = {i: copy.copy(m) for i, m in self.mutexes.items()} + k.events = {i: copy.copy(e) for i, e in self.events.items()} k.ready = {p: list(v) for p, v in self.ready.items()} k.timeouts = list(self.timeouts) k.trace = [] @@ -960,7 +1019,59 @@ def _record(self, t: Thread, result: Result): t.results = t.results + ((t.op_index, result, 0),) def _bound_to(self, tid: int) -> bool: - return any(s.binding is not None and s.binding[0] == tid for s in self.sems.values()) + return any(o.binding is not None and o.binding[0] == tid + for o in list(self.sems.values()) + list(self.events.values())) + + # ------------------------------------------------------------------ event flags (SPEC-005 §5, M2-05) + def _op_event_set(self, op: EventSet): + e = self.events[op.event] + if e.destroyed: + return + before = e.bits + e.bits |= op.bits + q = self.queues[e.qid] + for tid, _ in list(q.waiters): # queue order, one section + w = self.threads[tid] + got = event_satisfying(e.bits, w.notify_mask, w.notify_all) + if got: + if w.notify_clear: + e.bits &= ~got # before the next waiter (§5.2) + self.wake(w, Result.SATISFIED, got, q, handoff=True) + self._event("event_set", op.event, e.bits) + if e.binding is not None and e.bits != 0 and e.bits != before: # what direct waiters left + tid, bit = e.binding + self._notify_set(self.threads[tid], 1 << bit) + + def _op_event_clear(self, op: EventClear): + e = self.events[op.event] + if not e.destroyed: + e.bits &= ~op.bits + + def _op_event_wait(self, t: Thread, op: EventWait) -> bool: + e = self.events[op.event] + if t.pc == 0: + if e.destroyed: + self._finish(t, Result.STALE) + return True + t.notify_mask, t.notify_all, t.notify_clear = op.mask, op.all, op.clear # the wait's arguments + + def satisfied(): + return event_satisfying(e.bits, op.mask, op.all) != 0 + + def consume(): + got = event_satisfying(e.bits, op.mask, op.all) + if op.clear: + e.bits &= ~got + return got + return self._block_section(t, self.queues[e.qid], Reason.OBJECT, op.timeout, satisfied, consume) + + def _op_event_destroy(self, op: EventDestroy): + e = self.events[op.event] + if e.destroyed: + return + self.wake_all(self.queues[e.qid], Result.DESTROYED) + e.destroyed = True + e.binding = None def _op_bind(self, t: Thread, op: BindNotify) -> Result: sem, target = self.sems[op.sem], self.threads[op.tid] @@ -1049,6 +1160,17 @@ def step_thread(self) -> bool: self._op_destroy(op) self._advance(t) self.reschedule_if_needed() + elif isinstance(op, EventWait): + self._op_event_wait(t, op) + elif isinstance(op, (EventSet, EventClear, EventDestroy)): + if isinstance(op, EventSet): + self._op_event_set(op) + elif isinstance(op, EventClear): + self._op_event_clear(op) + else: + self._op_event_destroy(op) + self._advance(t) + self.reschedule_if_needed() elif isinstance(op, NONWAIT_RESULT_OPS): if isinstance(op, BindNotify): res = self._op_bind(t, op) @@ -1096,6 +1218,10 @@ def fire_isr(self, index: int): self._op_resume(op) elif isinstance(op, NotifySet): self._notify_set(self.threads[op.tid], op.bits) + elif isinstance(op, EventSet): + self._op_event_set(op) + elif isinstance(op, EventClear): + self._op_event_clear(op) elif isinstance(op, Start): self._op_start(op) self.irq_nesting -= 1 @@ -1149,6 +1275,11 @@ def check_invariants(self): for s in self.sems.values(): # ADR-046: no binding outlives its target if s.binding is not None and self.threads[s.binding[0]].destroyed: raise ModelError(f"semaphore {s.sid} is bound to destroyed thread {s.binding[0]}") + for e in self.events.values(): # SPEC-005 §5.2: a set served every waiter it satisfied + for tid, _ in self.queues[e.qid].waiters: + w = self.threads[tid] + if event_satisfying(e.bits, w.notify_mask, w.notify_all): + raise ModelError(f"event {e.eid} bits {e.bits:#x} satisfy queued waiter {tid}") if t.terminated and jq.waiters: raise ModelError(f"terminated thread {t.tid} still has a blocked joiner") if t.wait_state == WaitState.BLOCKED and t.wait_reason == Reason.NOTIFY and self._notify_satisfied(t): @@ -1241,4 +1372,5 @@ def outcome(self) -> tuple: ("sems", tuple((sid, s.count) for sid, s in sorted(self.sems.items()))), ("blocked", tuple(tid for tid, t in sorted(self.threads.items()) if t.wait_state == WaitState.BLOCKED)), ("mutex_owners", tuple((mid, m.owner) for mid, m in sorted(self.mutexes.items()))), - ("notify_bits", tuple((tid, t.notify_bits) for tid, t in sorted(self.threads.items())))) + ("notify_bits", tuple((tid, t.notify_bits) for tid, t in sorted(self.threads.items()))), + ("events", tuple((eid, e.bits) for eid, e in sorted(self.events.items())))) diff --git a/tools/model/evidence.py b/tools/model/evidence.py index 5c7a4a9..0f6879a 100644 --- a/tools/model/evidence.py +++ b/tools/model/evidence.py @@ -24,17 +24,21 @@ bridge.NotifySet, bridge.NotifyWait, bridge.Sleep, bridge.Yield, bridge.SetPrio, bridge.Suspend, bridge.Resume, bridge.Cancel, bridge.Destroy, bridge.SchedLock, bridge.SchedUnlock, bridge.Exit, bridge.Start, bridge.Join, - bridge.BindNotify, bridge.UnbindNotify, bridge.ThreadDestroy)} + bridge.BindNotify, bridge.UnbindNotify, bridge.ThreadDestroy, + bridge.EventSet, bridge.EventClear, bridge.EventWait, bridge.EventDestroy)} def scenario_data(sc): def program(ops): return [dict(op=type(op).__name__, args=asdict(op)) for op in ops] - return dict(name=sc.name, threads=[dict(tid=tid, priority=p, program=program(ops)) + data = dict(name=sc.name, threads=[dict(tid=tid, priority=p, program=program(ops)) for tid, (p, ops) in sc.threads.items()], sems=list(sc.sems.items()), mutexes=list(sc.mutexes.items()), isrs=[program(ops) for ops in sc.isrs], inactive=list(sc.inactive), owner_death=sc.owner_death, tickless=sc.tickless) + if sc.events: # absent from bundles that predate event flags + data["events"] = list(sc.events.items()) + return data def scenario_from_data(raw): @@ -74,6 +78,7 @@ def objects(items, enum_field=None, enum_type=None): return bridge.Scenario(name=raw["name"], threads=threads, sems=objects(raw["sems"], "policy", bridge.Policy), mutexes=objects(raw["mutexes"], "protocol", Protocol), + events=objects(raw.get("events", []), "policy", bridge.Policy), isrs=tuple(program(ops) for ops in raw["isrs"]), inactive=tuple(raw["inactive"]), owner_death=raw["owner_death"], tickless=raw["tickless"]) diff --git a/tools/model/tests/scenarios.py b/tools/model/tests/scenarios.py index 79e7083..9c886f2 100644 --- a/tools/model/tests/scenarios.py +++ b/tools/model/tests/scenarios.py @@ -411,3 +411,61 @@ def rebind_to_another_thread(): BINDING = [unbind_then_give, isr_give_vs_unbind, destroy_target_while_bound, destroy_bound_sem_releases_target, bind_terminated_target, suspended_target_accumulates, rebind_to_another_thread] ALL = ALL + BINDING + + +# --------------------------------------------------------------------------------------- +# M2-05: event flags (SPEC-005 §5) +# --------------------------------------------------------------------------------------- +from embmodel import EventDestroy, EventSet, EventWait # noqa: E402 + + +def event_overlapping_clear(): + """Two CLEAR waiters on the same bit: the higher one consumes the first set, the other the second.""" + return Scenario(name="event_overlapping_clear", + threads={1: (3, (EventWait(0, 0x1, clear=True),)), 2: (2, (EventWait(0, 0x1, clear=True),)), + 3: (1, (EventSet(0, 0x1), EventSet(0, 0x1)))}, + events={0: dict(bits=0)}) + + +def event_all_and_any(): + """An ALL waiter wakes only when both bits are set; an ANY waiter at the first one.""" + return Scenario(name="event_all_and_any", + threads={1: (3, (EventWait(0, 0x3, all=True),)), 2: (2, (EventWait(0, 0x1),)), + 3: (1, (EventSet(0, 0x1), EventSet(0, 0x2)))}, + events={0: dict(bits=0)}) + + +def event_isr_set_vs_timed_wait(): + """An ISR sets the bit at any point around a timed CLEAR wait: the bit is consumed or still set, never lost.""" + return Scenario(name="event_isr_set_vs_timed_wait", + threads={1: (2, (EventWait(0, 0x1, clear=True, timeout=3),))}, + events={0: dict(bits=0)}, + isrs=((EventSet(0, 0x1),),)) + + +def event_destroy_and_cancel_waiters(): + """One waiter leaves with DESTROYED, another with CANCELED.""" + return Scenario(name="event_destroy_and_cancel_waiters", + threads={1: (4, (EventWait(0, 0x1),)), 2: (3, (EventWait(1, 0x1),)), + 3: (1, (Sleep(1), Cancel(2), EventDestroy(0)))}, + events={0: dict(bits=0), 1: dict(bits=0)}) + + +def event_binding_after_direct_waiters(): + """The bound thread is notified only for bits the direct waiter left set.""" + return Scenario(name="event_binding_after_direct_waiters", + threads={1: (3, (NotifyWait(0x1, timeout=4),)), 2: (2, (EventWait(0, 0x1, clear=True),)), + 3: (1, (EventSet(0, 0x1), EventSet(0, 0x2)))}, + events={0: dict(bits=0, binding=(1, 0))}) + + +def event_timeout_then_set(): + """A waiter times out before the set; the bit stays set for later.""" + return Scenario(name="event_timeout_then_set", + threads={1: (2, (EventWait(0, 0x1, timeout=1),)), 2: (1, (Sleep(2), EventSet(0, 0x1)))}, + events={0: dict(bits=0)}) + + +EVENT = [event_overlapping_clear, event_all_and_any, event_isr_set_vs_timed_wait, event_destroy_and_cancel_waiters, + event_binding_after_direct_waiters, event_timeout_then_set] +ALL = ALL + EVENT diff --git a/tools/model/tests/test_explore.py b/tools/model/tests/test_explore.py index 4dd5168..2bebc19 100644 --- a/tools/model/tests/test_explore.py +++ b/tools/model/tests/test_explore.py @@ -272,3 +272,53 @@ def test_rebind_signals_the_new_target_only(self): [Result.SATISFIED, Result.EXIST, Result.SATISFIED, Result.SATISFIED]) self.assertEqual(d[2][0][1], Result.TIMEOUT) # the old target is never signaled self.assertIn(Result.SATISFIED, {d[3][0][1] for d in outs}) + + +class EventFlags(unittest.TestCase): + """M2-05 (SPEC-005 §5): ANY/ALL/CLEAR, deterministic consumption, no lost bit, binding after waiters.""" + + @staticmethod + def outcomes(make): + return [(dict(dict(o)["threads"]), dict(dict(o)["events"])) for o in explore(make()).outcomes] + + def test_every_event_scenario_holds_invariants(self): + for make in S.EVENT: + sc = make() + with self.subTest(scenario=sc.name): + self.assertGreater(explore(sc).terminals, 0) + + def test_overlapping_clear_serves_each_waiter_once(self): + for d, ev in self.outcomes(S.event_overlapping_clear): + self.assertEqual((d[1][0][1], d[1][0][2]), (Result.SATISFIED, 1)) + self.assertEqual((d[2][0][1], d[2][0][2]), (Result.SATISFIED, 1)) + self.assertEqual(ev[0], 0) + + def test_all_waits_for_every_bit(self): + for d, ev in self.outcomes(S.event_all_and_any): + self.assertEqual(d[1][0][2], 0x3) + self.assertEqual(d[2][0][2], 0x1) + self.assertEqual(ev[0], 0x3) + + def test_isr_set_is_consumed_or_kept(self): + seen = set() + for d, ev in self.outcomes(S.event_isr_set_vs_timed_wait): + r = d[1][0][1] + seen.add(r) + self.assertEqual(ev[0], 0 if r == Result.SATISFIED else 1) + self.assertEqual(seen, {Result.SATISFIED, Result.TIMEOUT}) + + def test_destroy_and_cancel_results(self): + for d, _ in self.outcomes(S.event_destroy_and_cancel_waiters): + self.assertEqual(d[1][0][1], Result.DESTROYED) + self.assertEqual(d[2][0][1], Result.CANCELED) + + def test_binding_fires_for_what_waiters_left(self): + for d, ev in self.outcomes(S.event_binding_after_direct_waiters): + self.assertEqual((d[2][0][1], d[2][0][2]), (Result.SATISFIED, 1)) + self.assertEqual(ev[0], 0x2) + self.assertIn(Result.SATISFIED, {d[1][0][1] for d, _ in self.outcomes(S.event_binding_after_direct_waiters)}) + + def test_timeout_leaves_the_later_bit_set(self): + for d, ev in self.outcomes(S.event_timeout_then_set): + self.assertEqual(d[1][0][1], Result.TIMEOUT) + self.assertEqual(ev[0], 1) diff --git a/tools/toolchain/native_abi.c b/tools/toolchain/native_abi.c index f96aed3..53b6613 100644 --- a/tools/toolchain/native_abi.c +++ b/tools/toolchain/native_abi.c @@ -127,6 +127,12 @@ void emb_native_abi_probe(void) FIELD(binding_thread, embk_binding_t, thread); FIELD(binding_bit, embk_binding_t, bit); #endif +#if CONFIG_EMB_EVENT + TYPE(event, embk_event_t); + FIELD(event_obj, embk_event_t, obj); + FIELD(event_waiters, embk_event_t, waiters); + FIELD(event_bits, embk_event_t, bits); +#endif #if CONFIG_EMB_MUTEX TYPE(mutex, embk_mutex_t); FIELD(mutex_obj, embk_mutex_t, obj); From 6264cdae9996e13a51f78cf6cfe0ae2ceb61046c Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 19:19:14 +0100 Subject: [PATCH 14/30] feat(sync): condition variables (M2-06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Condition variables (SPEC-005 §6, KRN-SYNC-030) under CONFIG_EMB_COND, on by default except on the tiny profile, and requiring mutexes. - A wait enqueues the caller and fully releases its mutex in one section, with hand-off and inheritance, so a signal sent after the release always finds the waiter. There is no stored signal and no spurious wakeup. Signal wakes the highest waiter; broadcast wakes all in queue order with one reschedule. Both are thread-only. - Every completion, including timeout, cancellation and destruction, relocks the mutex before returning. The relock goes through the mutex's own lock path (embk_mutex_lock_common) and defers cancellation (A41). EMB_EOWNERDEAD or a failed relock (EDEADLK, EDESTROYED) wins over the wait's result. A pending cancel or EMB_NO_WAIT returns before the mutex is released (A42). - The caller must own the mutex exactly once: not the owner is OWNER misuse, a recursive hold returns EMB_ESTATE. - mutex.c exposes its lock path and a release helper for this; emb_mutex_unlock() uses the helper. API IDs 95-101, trace IDs 33/34. The reference model gains Cond state and CondWait in three steps (wait on the condition, relock with cancellation deferred, report), plus CondSignal, CondBroadcast and CondDestroy. A ModelError is raised if a satisfied wait returns without the mutex. The blocking section now emits wait_begin before its enqueue hook, matching the kernel's trace order. The COND scenario group covers the lost-signal race, a relock after timeout, a cancel during the relock, broadcast and destroy. The runner and bridge carry condition variables; tiny and archived captures without a "conds" key skip them. tests/conformance/ test_cond.c covers the contract. Signed-off-by: teo1747 --- CHANGELOG.md | 2 + docs/STATUS.md | 4 +- docs/engineering/m2-checklist.md | 2 +- docs/engineering/resource-bounds.json | 30 ++++ docs/engineering/resource-bounds.md | 6 + docs/requirements/KRN-SYNC.md | 4 +- docs/specs/AMENDMENTS-M2.md | 2 + docs/specs/SPEC-005-synchronization.md | 6 +- docs/user/feature-status.md | 2 +- docs/user/synchronization.md | 18 +++ include/emb/api_ids.h | 7 + include/emb/cond.h | 174 ++++++++++++++++++++ include/emb/emb.h | 3 + include/emb/trace.h | 10 +- kernel/CMakeLists.txt | 3 + kernel/Kconfig | 6 + kernel/include/embk/kernel.h | 1 + kernel/include/embk/sync.h | 28 ++++ kernel/object/storage_probe.c | 3 + kernel/sync/cond.c | 210 +++++++++++++++++++++++++ kernel/sync/mutex.c | 21 ++- tests/conformance/CMakeLists.txt | 3 + tests/conformance/test_cond.c | 162 +++++++++++++++++++ tests/differential/diff_runner.c | 109 +++++++++++-- tools/fault/api-ids.json | 42 +++++ tools/fault/api-registry.json | 42 +++++ tools/model/bridge.py | 30 +++- tools/model/embmodel/__init__.py | 2 +- tools/model/embmodel/kernel.py | 159 +++++++++++++++++-- tools/model/evidence.py | 6 +- tools/model/tests/scenarios.py | 51 ++++++ tools/model/tests/test_explore.py | 39 +++++ tools/toolchain/native_abi.c | 5 + 33 files changed, 1139 insertions(+), 53 deletions(-) create mode 100644 include/emb/cond.h create mode 100644 kernel/sync/cond.c create mode 100644 tests/conformance/test_cond.c diff --git a/CHANGELOG.md b/CHANGELOG.md index bfca986..f98f67a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Added +- Condition variables (`CONFIG_EMB_COND`, `include/emb/cond.h`, M2-06): an atomic enqueue-and-release wait, signal and broadcast, and a mandatory, non-cancelable relock on every result, with `EMB_EOWNERDEAD` and relock failures taking precedence. The reference model, the differential bridge and a conformance suite cover them; they are off on the tiny profile. + - Event flags (`CONFIG_EMB_EVENT`, `include/emb/event.h`, M2-05): ANY/ALL waits with optional CLEAR, deterministic consumption in queue order, ISR-safe set and clear, timeouts, cancellation, destroy with ABORT_WAITERS, and notification binding with unbind. The reference model, the differential bridge and a conformance suite cover them; they are off in the tiny `footprint_t1` reference configuration. - A reviewed resource-bound inventory (`docs/engineering/resource-bounds.json` and its page) for every cost input in the public `@time` annotations, with `tools/evidence/check_bounds.py` failing CI when a non-constant cost has no entry (M2-04). diff --git a/docs/STATUS.md b/docs/STATUS.md index aa3a02c..79cc431 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -10,7 +10,7 @@ This page is the authoritative inventory of implementation and available evidenc |---|---|---| | Execution and scheduling | UP fixed priority, base bitmap/FIFO scheduler, tiny thread table, scheduler lock, thread lifecycle; idle context unreachable through handles, and slim (control-block prefix only) on tiny without an idle thread (ADR-050) | Common conformance cases; tiny has unique base priorities and restricted scheduling semantics | | Waiting and time | Shared wait protocol, sleep, deadlines, cancellation, suspend overlay, ordered timeouts | Native virtual time exercises periodic and tickless modes; embedded ports currently reject tickless | -| Synchronization | Semaphores, mutexes with inheritance, ceiling, recursion and owner-death policy; event flags with ANY/ALL/CLEAR waits and binding (M2-05) | Conformance and native differential bridge; inheritance and deadlock detection have a configured depth limit | +| Synchronization | Semaphores, mutexes with inheritance, ceiling, recursion and owner-death policy; event flags with ANY/ALL/CLEAR waits and binding (M2-05); condition variables with the mandatory relock (M2-06) | Conformance and native differential bridge; inheritance and deadlock detection have a configured depth limit | | Notifications | Per-thread bits and one semaphore-to-thread binding, with unbind; a bound thread cannot be destroyed (ADR-046) | Binding takes a one-bit mask; other objects' bindings arrive with those objects; restart generations need the isolated profile | | Objects | Caller-owned generated storage and POINTER handles; per-type active-object lists for semaphores and mutexes (`CONFIG_EMB_OBJ_LIST`) used by init | No INDEXED or TABLE handles, capability rights, partition restart, system freeze, `emb_obj_foreach()` or debug-descriptor export | | Native port | POSIX host-thread gate, deterministic IRQ injection, virtual time | Base/tiny development workflow; no current wall-clock, asynchronous signal stress, simulated isolated profile or Win32 backend | @@ -23,7 +23,7 @@ This page is the authoritative inventory of implementation and available evidenc ## Specified but unimplemented -Condition variables, barriers, software timers, work queues, message queues, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. +Barriers, software timers, work queues, message queues, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. An API or path named in a design or a requirement's verification field may be a planned deliverable. Check this inventory and the filesystem before treating it as available. diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index 1957812..64c2d13 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -76,7 +76,7 @@ The `footprint_t1` reference build was at the R-003 T2 limit of 64 bytes of kern ## M2-05 through M2-09: Primitive acceptance - [x] **Event flags:** model overlapping masks and ANY/ALL/CLEAR; test deterministic waiter order, clearing before the next waiter, multi-wake cost, ISR set, timeout/cancel/destroy and binding semantics. Follow SPEC-005 and KRN-SYNC-029. -- [ ] **Condition variables:** model atomic mutex release/enqueue, no stored signal, and mandatory mutex reacquisition even after timeout/cancel; test broadcasts, priority inheritance, recursive-mutex restrictions and relock response-time implications. Follow SPEC-005 and KRN-SYNC-030. +- [x] **Condition variables:** model atomic mutex release/enqueue, no stored signal, and mandatory mutex reacquisition even after timeout/cancel; test broadcasts, priority inheritance, recursive-mutex restrictions and relock response-time implications. Follow SPEC-005 and KRN-SYNC-030. - [ ] **Message queues:** start with the copy-by-value subset; validate item-size/capacity arithmetic and aligned caller storage, direct receiver/slot hand-off, front send, no-wait ISR paths, absolute deadlines, cancellation/destruction and readiness recheck. Bound bytes copied while masked. Follow SPEC-007/KRN-IPC; ownership queues depend on a later pool gate and must not be advertised early. - [ ] **Work queues:** define callback thread/priority and serialized execution; model submit/resubmit states and cancel/flush including callback self-wait misuse, delayed scheduling and teardown. Follow SPEC-006/KRN-WQ; expose only implemented configurations. - [ ] **Software timers:** use the work queue for default callbacks; model absolute periodic rearm, missed-period policy and checked overflow arithmetic, cancel/destroy while callbacks are queued/running, and any explicitly supported ISR callback mode. Follow SPEC-003/KRN-TIM; do not silently enable embedded tickless before its port contract is implemented. diff --git a/docs/engineering/resource-bounds.json b/docs/engineering/resource-bounds.json index 041ef6d..72ab73c 100644 --- a/docs/engineering/resource-bounds.json +++ b/docs/engineering/resource-bounds.json @@ -268,6 +268,36 @@ "emb_event_wait_until": [ "waiters", "timeouts" + ], + "emb_cond_init": [ + "active_objects" + ], + "emb_cond_destroy": [ + "batch_wake", + "reschedule" + ], + "emb_cond_wait": [ + "waiters", + "timeouts", + "owned", + "pi_depth", + "reschedule" + ], + "emb_cond_wait_until": [ + "waiters", + "timeouts", + "owned", + "pi_depth", + "reschedule" + ], + "emb_cond_signal": [ + "timeouts", + "reschedule" + ], + "emb_cond_broadcast": [ + "batch_wake", + "timeouts", + "reschedule" ] } } diff --git a/docs/engineering/resource-bounds.md b/docs/engineering/resource-bounds.md index 24715bb..da03349 100644 --- a/docs/engineering/resource-bounds.md +++ b/docs/engineering/resource-bounds.md @@ -25,6 +25,12 @@ Every cost input named by a public function's `@time` annotation, with how it is | Function | `@time` | Inputs | |---|---|---| +| `emb_cond_broadcast()` | O(waiters) in one section | batch_wake, timeouts, reschedule | +| `emb_cond_destroy()` | O(waiters) with EMB_OBJ_ABORT_WAITERS | batch_wake, reschedule | +| `emb_cond_init()` | O(1); O(active condition variables) list lookup with CONFIG_EMB_OBJ_LIST | active_objects | +| `emb_cond_signal()` | O(1) selection, plus profile-specific timeout removal and scheduling | timeouts, reschedule | +| `emb_cond_wait()` | O(waiters + timeouts) enqueue, a full mutex release, then the cost of emb_mutex_lock() | waiters, timeouts, owned, pi_depth, reschedule | +| `emb_cond_wait_until()` | as emb_cond_wait() | waiters, timeouts, owned, pi_depth, reschedule | | `emb_event_destroy()` | O(waiters) with EMB_OBJ_ABORT_WAITERS | batch_wake, reschedule | | `emb_event_init()` | O(1); O(active event flags) list lookup with CONFIG_EMB_OBJ_LIST | active_objects | | `emb_event_set()` | O(waiters) in one section, one reschedule | waiters, batch_wake, timeouts, reschedule | diff --git a/docs/requirements/KRN-SYNC.md b/docs/requirements/KRN-SYNC.md index 566ea41..2ab0d8d 100644 --- a/docs/requirements/KRN-SYNC.md +++ b/docs/requirements/KRN-SYNC.md @@ -216,8 +216,8 @@ KRN-SYNC-001 to 007 originate in the v0.1 specification (§8.6); KRN-SYNC-008 to ### KRN-SYNC-030 Condition variable semantics **Statement.** `cond_wait` shall require ownership with recursion count 1, shall enqueue and then fully unlock the mutex atomically with respect to `signal`, and shall relock the mutex before returning on every result; `signal` shall wake the highest waiter and `broadcast` all; the kernel shall never wake a condition waiter without a signal or broadcast. **Rationale.** Classic, analyzable semantics; the relock-always rule is what callers can reason about. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `sync/cond_*` including relock after timeout; `misuse/cond_wait_not_owner`. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-06). +**Verification.** Test: `cond_signal_wakes_one_waiter_which_owns_the_mutex`, `cond_broadcast_wakes_all_in_priority_order`, `cond_signal_is_not_stored`, `cond_relock_after_timeout_waits_for_the_mutex`, `cond_cancel_returns_with_the_mutex_owned`, `cond_destroy_aborts_waiters_which_relock`, `cond_wait_requires_single_ownership`; model scenarios of the COND group (no lost signal, deferred cancel during the relock); differential agreement on the native base presets. **Trace.** SPEC-005 §6 ### KRN-SYNC-031 Barrier semantics diff --git a/docs/specs/AMENDMENTS-M2.md b/docs/specs/AMENDMENTS-M2.md index ad6543b..742929a 100644 --- a/docs/specs/AMENDMENTS-M2.md +++ b/docs/specs/AMENDMENTS-M2.md @@ -16,3 +16,5 @@ Corrections found while preparing the M2 primitives, where an accepted specifica | A38 | SPEC-005 §5.2 | An event waiter's mask lives in `wake_data` until a waker replaces it with the handed-off bits, and its mode in the thread's two bit-wait mode bits (ALL, CLEAR), shared with notification waits; the TCB does not grow, which the tiny profile's 32-byte budget requires. | `kernel/sync/event.c`, `kernel/include/embk/kernel.h` | | A39 | SPEC-005 §5.2, §8 | `emb_event_wait()` with a zero mask or an unknown mode bit is an argument misuse; `set` and `clear` of zero bits are valid no-ops. | `kernel/sync/event.c` | | A40 | SPEC-005 §5.1, SPEC-001 §6.4 | Event flags get `emb_event_attr_default()` and `emb_event_unbind_notify()`; like semaphores and mutexes they have no `_DEFINE` macro yet, which stays one cross-type change. | `include/emb/event.h` | +| A41 | SPEC-005 §6 | The mandatory relock of a condition wait defers cancellation (the thread's cancel-disable count is raised around it), so a cancel cannot return the caller without its mutex; it is delivered at the next cancellation point. | `kernel/sync/cond.c` | +| A42 | SPEC-005 §6 | Condition wait results: `EMB_EOWNERDEAD` or a relock failure (`EMB_EDEADLK`, `EMB_EDESTROYED`) wins over the wait's result; a pending cancel or `EMB_NO_WAIT` returns before the mutex is released. `emb_cond_attr_default()` and `emb_cond_attr_t` (name, flags) are defined. | `include/emb/cond.h` | diff --git a/docs/specs/SPEC-005-synchronization.md b/docs/specs/SPEC-005-synchronization.md index 543c14a..ddd8618 100644 --- a/docs/specs/SPEC-005-synchronization.md +++ b/docs/specs/SPEC-005-synchronization.md @@ -258,10 +258,14 @@ emb_status_t emb_cond_wait(emb_cond_t c, emb_mutex_t m, emb_timeout_t timeout); emb_status_t emb_cond_wait_until(emb_cond_t c, emb_mutex_t m, emb_instant_t deadline); emb_status_t emb_cond_signal(emb_cond_t c); /* @ctx thread @blocks no O(1) */ emb_status_t emb_cond_broadcast(emb_cond_t c); /* @ctx thread @blocks no O(waiters) */ +void emb_cond_attr_default(emb_cond_attr_t *out); /* emb_cond_attr_t: name, flags (ABORT_WAITERS | FIFO) */ ``` +Implemented in M2 (`CONFIG_EMB_COND`, default on except the tiny profile; requires mutexes). + - `wait` requires the caller to own `m` with recursion count 1 (`EMB_EPERM` if not owner, `EMB_ESTATE` if count > 1). In section 1 it enqueues on `c`, then performs a full `unlock(m)` (§3.3, including hand-off to a mutex waiter) before releasing the object lock; the enqueue-then-unlock order makes the release and the wait atomic with respect to any `signal`. -- On any wake result the caller re-locks `m` with `EMB_WAIT_FOREVER` before returning, so the caller always owns `m` on return, including after `EMB_ETIMEDOUT`, `EMB_ECANCELED`, and `EMB_EDESTROYED`. The relock may block beyond the timeout; this is documented, and the relock is itself an inheritance-aware mutex lock. +- On any wake result the caller re-locks `m` with `EMB_WAIT_FOREVER` before returning, so the caller always owns `m` on return, including after `EMB_ETIMEDOUT`, `EMB_ECANCELED`, and `EMB_EDESTROYED`. The relock may block beyond the timeout; this is documented, and the relock is itself an inheritance-aware mutex lock. The relock is not a cancellation point: a cancel that arrives during it stays pending for the next one (A41). +- Result precedence (A42): `EMB_EOWNERDEAD` from the relock wins over the wait's result, since the caller owns an inconsistent mutex either way; a failed relock (`EMB_EDEADLK` in a cycle, `EMB_EDESTROYED` when the mutex is destroyed with `ABORT_WAITERS`) also wins, and then the caller does not own `m`. A pending cancel before the enqueue returns `EMB_ECANCELED` without releasing `m`, and `EMB_NO_WAIT` returns `EMB_ETIMEDOUT` at once with `m` kept, since there is no stored signal to find. - `signal` wakes the highest waiter (hand-off word 0); `broadcast` wakes all in queue order with one reschedule. Both may be called with or without the mutex held; calling them while holding it avoids the "woken, then blocks on the mutex" bounce, and the documentation recommends it. Neither is ISR-safe: a condition variable pairs with a mutex, and a mutex is never touched from an ISR. - The kernel never generates spurious wakeups (a wake without a signal or broadcast); callers still re-check their predicate in a loop, because another thread may change it between the signal and the relock. - `cond_wait` on a `CEILING` or `NONE` mutex is allowed; the relock follows that mutex's protocol. diff --git a/docs/user/feature-status.md b/docs/user/feature-status.md index e76aa06..7191b4e 100644 --- a/docs/user/feature-status.md +++ b/docs/user/feature-status.md @@ -5,7 +5,7 @@ This guide describes the current 0.2 development implementation. The authoritati | Goal | Available now | Still planned or limited | |---|---|---| | Start and coordinate threads | Static storage, priorities, start, sleep, join/detach, cooperative cancellation | Isolated partitions, budgets, restart, SMP | -| Protect or signal work | Mutexes, semaphores, per-thread notifications, event flags | Condition variables, barriers | +| Protect or signal work | Mutexes, semaphores, per-thread notifications, event flags, condition variables | Barriers | | Transfer payloads | Application-owned data with an explicit synchronization protocol | Message queues, pipes, buffer pools | | Run periodic work | A thread using absolute sleep deadlines | Software timers and system work queues | | Wait on several readiness sources | Semaphore and event flag bindings, with unbind, plus notification bits and rechecking; a bound thread cannot be destroyed | Message queue bindings, restart-safe target lifetime | diff --git a/docs/user/synchronization.md b/docs/user/synchronization.md index f59bebd..1385465 100644 --- a/docs/user/synchronization.md +++ b/docs/user/synchronization.md @@ -145,3 +145,21 @@ emb_status_t st = emb_event_wait(status, LINK_UP | CONFIGURED, EMB_EVENT_ALL, ``` `emb_event_set()` and `emb_event_clear()` are ISR-safe; waits are thread-only. A set serves waiters in priority order (arrival order with `EMB_OBJ_FIFO`). A `CLEAR` waiter removes the bits it was handed before the next waiter is examined, so two clearing waiters on the same bit are served one per set, highest priority first. `emb_event_clear()` never wakes anyone. A woken waiter receives the bits that satisfied it in `got` and does not re-read the word. A bound notification fires only for bits still set after the direct waiters were served; bind and unbind follow the semaphore rules above. + +## Condition variables + +A condition variable (`CONFIG_EMB_COND`) lets a thread wait for a predicate over state that a mutex protects. Check the predicate in a loop with the mutex held; `emb_cond_wait()` releases the mutex and enqueues the caller in one step, so a signal sent after the release is never missed: + +```c +EMB_CHECK(emb_mutex_lock(lock, EMB_WAIT_FOREVER)); +while (!ready) { + emb_status_t st = emb_cond_wait(changed, lock, EMB_TIMEOUT(EMB_MS(100))); + if (st != EMB_OK && st != EMB_ETIMEDOUT) { + break; /* canceled, destroyed, or EMB_EOWNERDEAD: still the owner except on a failed relock */ + } +} +/* ... use the protected state ... */ +EMB_CHECK(emb_mutex_unlock(lock)); +``` + +The waiter relocks the mutex before every return, including timeouts, cancellation and destruction, so the relock can take longer than the timeout. The relock is not a cancellation point. A signal with no waiter is lost, and there are no spurious wakeups, but recheck the predicate anyway: another thread can change it between the signal and the relock. The caller must hold the mutex exactly once; a recursively held mutex returns `EMB_ESTATE`. `emb_cond_signal()` wakes the highest-priority waiter and `emb_cond_broadcast()` wakes all of them. Both are thread-only. diff --git a/include/emb/api_ids.h b/include/emb/api_ids.h index 1e25d76..2048f1d 100644 --- a/include/emb/api_ids.h +++ b/include/emb/api_ids.h @@ -98,5 +98,12 @@ #define EMB_API_ID_EVENT_WAIT_UNTIL 0x005Cu #define EMB_API_ID_EVENT_BIND_NOTIFY 0x005Du #define EMB_API_ID_EVENT_UNBIND_NOTIFY 0x005Eu +#define EMB_API_ID_COND_ATTR_DEFAULT 0x005Fu +#define EMB_API_ID_COND_INIT 0x0060u +#define EMB_API_ID_COND_DESTROY 0x0061u +#define EMB_API_ID_COND_WAIT 0x0062u +#define EMB_API_ID_COND_WAIT_UNTIL 0x0063u +#define EMB_API_ID_COND_SIGNAL 0x0064u +#define EMB_API_ID_COND_BROADCAST 0x0065u #endif /* EMB_API_IDS_H */ diff --git a/include/emb/cond.h b/include/emb/cond.h new file mode 100644 index 0000000..77eb6f7 --- /dev/null +++ b/include/emb/cond.h @@ -0,0 +1,174 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* Condition variables (SPEC-005 §6): wait with a mutex released atomically, no stored signal. */ +#ifndef EMB_COND_H +#define EMB_COND_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +EMB_DECLARE_HANDLE(emb_cond_t); + +/* Defined by the generated . */ +typedef union emb_cond_storage emb_cond_storage_t; + +typedef struct emb_cond_attr { + const char *name; + uint8_t flags; /* EMB_OBJ_ABORT_WAITERS | EMB_OBJ_FIFO */ + uint8_t reserved_[3]; /* explicit padding */ +} emb_cond_attr_t; + +/** + * emb_cond_attr_default() - Fill condition variable attributes with defaults. + * @out_attr: Receives name NULL and flags 0; NULL is a no-op. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_COND + * @req API-020 KRN-SYNC-030 + * @since 0.3 + * @stable no + */ +void emb_cond_attr_default(emb_cond_attr_t *out_attr); + +/** + * emb_cond_init() - Construct a condition variable in caller-provided storage. + * @storage: Aligned caller-owned storage, never initialized or destroyed; retain it until + * successful destruction. + * @attr: Copied attributes; NULL selects defaults. Keep name storage alive. + * @out_cond: Required output handle; cleared before argument validation. + * + * @ctx prekernel thread + * @blocks no + * @time O(1); O(active condition variables) list lookup with CONFIG_EMB_OBJ_LIST + * @owns borrows:storage + * @config CONFIG_EMB_COND + * @req API-021 KRN-SYNC-030 KRN-OBJ-003 KRN-OBJ-007 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EINVAL for bad storage or output; EMB_EBUSY when @storage still + * holds a condition variable that was not destroyed (misuse; exact with + * CONFIG_EMB_OBJ_LIST). + */ +emb_status_t emb_cond_init(emb_cond_storage_t *storage, const emb_cond_attr_t *attr, + emb_cond_t *out_cond); + +/** + * emb_cond_destroy() - End the condition variable (KRN-OBJ-001). + * @cond: Initialized condition variable; no waiters unless ABORT_WAITERS is set. + * + * @ctx thread + * @blocks no + * @time O(waiters) with EMB_OBJ_ABORT_WAITERS + * @owns gives:storage + * @config CONFIG_EMB_COND + * @req KRN-OBJ-001 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EBUSY with waiters and no ABORT_WAITERS (misuse). Aborted waiters + * still relock their mutex before emb_cond_wait() returns EMB_EDESTROYED. + */ +emb_status_t emb_cond_destroy(emb_cond_t cond); + +/** + * emb_cond_wait() - Release @mutex, wait for a signal, and relock @mutex. + * @cond: Initialized condition variable. + * @mutex: A mutex the caller owns exactly once (recursion count 1). + * @timeout: EMB_NO_WAIT (returns EMB_ETIMEDOUT at once, mutex kept), EMB_WAIT_FOREVER or + * EMB_TIMEOUT(duration); the relock may block beyond it. + * + * @ctx thread + * @blocks timeout, then the relock + * @time O(waiters + timeouts) enqueue, a full mutex release, then the cost of + * emb_mutex_lock() + * @owns none + * @config CONFIG_EMB_COND + * @req KRN-SYNC-030 KRN-SYNC-020 KRN-WAIT-012 + * @since 0.3 + * @stable no + * + * The enqueue and the release are one section, so a signal sent after the release always + * finds the waiter. There are no spurious wakeups and no stored signals. On every result, + * including timeout, cancellation and destruction, the caller owns @mutex again on return; + * the relock is not cancelable (a pending cancel is delivered at the next point). + * + * Return: EMB_OK when signaled; EMB_ETIMEDOUT; EMB_ECANCELED; EMB_EDESTROYED; + * EMB_EOWNERDEAD when the relock inherited an inconsistent mutex, whatever the + * wait's result (the caller owns it and repairs its state); the relock's own + * failure, EMB_EDEADLK or EMB_EDESTROYED, after which the caller does not own + * @mutex (checked builds fault on the deadlock); EMB_ESTATE when the + * recursion count is above 1; EMB_EPERM (misuse) when the caller does not own + * @mutex or is not a thread; EMB_EINVAL for a bad handle. + */ +emb_status_t emb_cond_wait(emb_cond_t cond, emb_mutex_t mutex, emb_timeout_t timeout); + +/** + * emb_cond_wait_until() - emb_cond_wait() with an absolute deadline. + * @cond: Initialized condition variable. + * @mutex: A mutex the caller owns exactly once. + * @deadline: Kernel-clock deadline for the wait; a past deadline returns EMB_ETIMEDOUT + * with the mutex kept. The relock may block beyond it. + * + * @ctx thread + * @blocks timeout, then the relock + * @time as emb_cond_wait() + * @owns none + * @config CONFIG_EMB_COND + * @req API-024 KRN-TIM-011 KRN-SYNC-030 + * @since 0.3 + * @stable no + * + * Return: the statuses of emb_cond_wait(). + */ +emb_status_t emb_cond_wait_until(emb_cond_t cond, emb_mutex_t mutex, emb_instant_t deadline); + +/** + * emb_cond_signal() - Wake the first waiter, if any; a signal with no waiter is lost. + * @cond: Initialized condition variable. Holding the waiters' mutex while signaling is + * recommended, not required. + * + * @ctx thread + * @blocks no + * @time O(1) selection, plus profile-specific timeout removal and scheduling + * @owns none + * @config CONFIG_EMB_COND + * @req KRN-SYNC-030 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EINVAL for a bad handle; EMB_EPERM (misuse) outside thread context. + */ +emb_status_t emb_cond_signal(emb_cond_t cond); + +/** + * emb_cond_broadcast() - Wake every waiter, in queue order, with one reschedule. + * @cond: Initialized condition variable. + * + * @ctx thread + * @blocks no + * @time O(waiters) in one section + * @owns none + * @config CONFIG_EMB_COND + * @req KRN-SYNC-030 KRN-WAIT-013 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EINVAL for a bad handle; EMB_EPERM (misuse) outside thread context. + */ +emb_status_t emb_cond_broadcast(emb_cond_t cond); + +#ifdef __cplusplus +} +#endif + +#endif /* EMB_COND_H */ diff --git a/include/emb/emb.h b/include/emb/emb.h index c04882d..7aba1b6 100644 --- a/include/emb/emb.h +++ b/include/emb/emb.h @@ -6,6 +6,9 @@ #include #include +#if CONFIG_EMB_COND +#include +#endif #include #if CONFIG_EMB_EVENT #include diff --git a/include/emb/trace.h b/include/emb/trace.h index 16cfeae..be694fc 100644 --- a/include/emb/trace.h +++ b/include/emb/trace.h @@ -45,10 +45,12 @@ enum emb_trace_id { EMB_TRACE_PI_DEPTH = 26, /* a0 = index, a1 = depth reached */ EMB_TRACE_IDLE_ENTER = 27, EMB_TRACE_IDLE_EXIT = 28, - EMB_TRACE_TICK = 29, /* a0 = ticks low word */ - EMB_TRACE_SLEEP = 30, /* a0 = index, a1 = deadline low word */ - EMB_TRACE_EVENT_SET = 31, /* a0 = waiters woken, a1 = event address, a2 = bits after */ - EMB_TRACE_EVENT_WAIT = 32 /* a0 = index, a1 = event address, a2 = status */ + EMB_TRACE_TICK = 29, /* a0 = ticks low word */ + EMB_TRACE_SLEEP = 30, /* a0 = index, a1 = deadline low word */ + EMB_TRACE_EVENT_SET = 31, /* a0 = waiters woken, a1 = event address, a2 = bits after */ + EMB_TRACE_EVENT_WAIT = 32, /* a0 = index, a1 = event address, a2 = status */ + EMB_TRACE_COND_WAIT = 33, /* a0 = index, a1 = cond address, a2 = status after the relock */ + EMB_TRACE_COND_SIGNAL = 34 /* a0 = waiters woken, a1 = cond address, a2 = 1 for broadcast */ }; typedef struct emb_trace_event { diff --git a/kernel/CMakeLists.txt b/kernel/CMakeLists.txt index 9518ab0..2349a92 100644 --- a/kernel/CMakeLists.txt +++ b/kernel/CMakeLists.txt @@ -27,6 +27,9 @@ endif() if(CONFIG_EMB_EVENT) list(APPEND EMB_KERNEL_SOURCES sync/event.c) endif() +if(CONFIG_EMB_COND) + list(APPEND EMB_KERNEL_SOURCES sync/cond.c) +endif() add_library(emb_kernel STATIC ${EMB_KERNEL_SOURCES}) emb_use_kernel_compiler(emb_kernel) diff --git a/kernel/Kconfig b/kernel/Kconfig index 33ae0ba..9845ec0 100644 --- a/kernel/Kconfig +++ b/kernel/Kconfig @@ -167,6 +167,12 @@ config EMB_MUTEX bool "Mutexes (SPEC-005 §3)" default y +config EMB_COND + bool "Condition variables (SPEC-005 §6)" + depends on EMB_MUTEX + default n if EMB_PROFILE_TINY + default y + config EMB_MUTEX_INHERIT bool "Priority inheritance protocol" depends on EMB_MUTEX diff --git a/kernel/include/embk/kernel.h b/kernel/include/embk/kernel.h index bac2726..2e96e4c 100644 --- a/kernel/include/embk/kernel.h +++ b/kernel/include/embk/kernel.h @@ -40,6 +40,7 @@ #define EMBK_OBJ_SEM ((uint8_t)0x52u) #define EMBK_OBJ_MUTEX ((uint8_t)0x53u) #define EMBK_OBJ_EVENT ((uint8_t)0x54u) +#define EMBK_OBJ_COND ((uint8_t)0x55u) #define EMBK_OBJ_UNINIT ((uint8_t)0u) #define EMBK_OBJ_ACTIVE ((uint8_t)1u) diff --git a/kernel/include/embk/sync.h b/kernel/include/embk/sync.h index 6158554..a7634ac 100644 --- a/kernel/include/embk/sync.h +++ b/kernel/include/embk/sync.h @@ -4,6 +4,7 @@ #ifndef EMBK_SYNC_H #define EMBK_SYNC_H +#include #include #include @@ -81,6 +82,33 @@ void embk_mutex_on_waiters_changed(embk_mutex_t *m); /* Thread exit (SPEC-005 §3.6, SPEC-008 §5 step 2), lock held. */ void embk_mutex_release_all_on_exit(embk_thread_t *t); + +/* The lock path of emb_mutex_lock() with an explicit public entry for misuse faults; + * condition variables relock through it (SPEC-005 §6). */ +emb_status_t embk_mutex_lock_common(uint16_t api_id, embk_mutex_t *m, emb_tick_t deadline, + bool nowait); + +/* Full release of a mutex @self owns once, with hand-off and inheritance; lock held. */ +void embk_mutex_release_locked(embk_mutex_t *m, embk_thread_t *self); + +static EMB_INLINE embk_mutex_t *embk_mutex_from_handle(emb_mutex_t h) +{ + return (embk_mutex_t *)embk_obj_from_raw(h.raw, EMBK_OBJ_MUTEX, offsetof(embk_mutex_t, obj)); +} + +#if CONFIG_EMB_COND +typedef struct embk_cond { + embk_obj_t obj; /* obj.flags: EMB_OBJ_ABORT_WAITERS | EMB_OBJ_FIFO */ + embk_wait_queue_t waiters; +#if CONFIG_EMB_OBJ_LIST + embk_list_node_t all_node; /* in embk_cond_all from init to destroy */ +#endif +} embk_cond_t; + +#if CONFIG_EMB_OBJ_LIST +extern embk_list_t embk_cond_all; /* every active condition variable; lock: scheduler domain */ +#endif +#endif #else static EMB_INLINE bool embk_prio_recompute(embk_thread_t *t) { diff --git a/kernel/object/storage_probe.c b/kernel/object/storage_probe.c index e6c067e..a0db3bf 100644 --- a/kernel/object/storage_probe.c +++ b/kernel/object/storage_probe.c @@ -32,4 +32,7 @@ void embk_storage_probe(void) #if CONFIG_EMB_EVENT PROBE(event, embk_event_t); #endif +#if CONFIG_EMB_COND + PROBE(cond, embk_cond_t); +#endif } diff --git a/kernel/sync/cond.c b/kernel/sync/cond.c new file mode 100644 index 0000000..5b86ba9 --- /dev/null +++ b/kernel/sync/cond.c @@ -0,0 +1,210 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* + * Condition variables (SPEC-005 §6). A wait enqueues the caller and releases its mutex in + * one section, so no signal sent after the release can be missed; there is no stored + * signal and no spurious wakeup. Every completion relocks the mutex before returning, with + * cancellation deferred so that the caller always owns it again (A41). + */ +#include +#include + +#include +#include +#include +#include +#include + +#if CONFIG_EMB_COND + +EMB_STATIC_ASSERT(sizeof(embk_cond_t) <= EMB_COND_STORAGE_SIZE, "generated storage too small"); + +#if CONFIG_EMB_OBJ_LIST +embk_list_t embk_cond_all = EMBK_LIST_INITIALIZER(embk_cond_all); /* usable before kernel init */ +#endif + +static EMB_INLINE embk_cond_t *cond_from_handle(emb_cond_t h) +{ + return (embk_cond_t *)embk_obj_from_raw(h.raw, EMBK_OBJ_COND, offsetof(embk_cond_t, obj)); +} + +void emb_cond_attr_default(emb_cond_attr_t *out_attr) +{ + if (out_attr == NULL) { + return; + } + out_attr->name = NULL; + out_attr->flags = 0u; + out_attr->reserved_[0] = 0u; + out_attr->reserved_[1] = 0u; + out_attr->reserved_[2] = 0u; +} + +emb_status_t emb_cond_init(emb_cond_storage_t *storage, const emb_cond_attr_t *attr, + emb_cond_t *out_cond) +{ + embk_cond_t *c; + emb_cond_attr_t def; +#if CONFIG_EMB_OBJ_LIST + emb_irq_key_t key; +#endif + if (out_cond != NULL) { + out_cond->raw = 0u; + } + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_COND_INIT); + EMBK_API_REQUIRE(EMB_API_ID_COND_INIT, storage != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + EMBK_API_REQUIRE(EMB_API_ID_COND_INIT, out_cond != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, + 3u); + EMBK_API_REQUIRE(EMB_API_ID_COND_INIT, + ((uintptr_t)storage % (uintptr_t)EMB_COND_STORAGE_ALIGN) == 0u, + EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + if (attr == NULL) { + emb_cond_attr_default(&def); + attr = &def; + } + c = (embk_cond_t *)(void *)storage; +#if CONFIG_EMB_OBJ_LIST + /* storage is reused only through destroy (SPEC-009 §6, KRN-OBJ-007) */ + key = embk_lock_sched(); + if (embk_list_contains(&embk_cond_all, &c->all_node)) { + embk_unlock_sched(key); + EMBK_API_MISUSE(EMB_API_ID_COND_INIT, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); + } +#endif + embk_obj_init(&c->obj, EMBK_OBJ_COND, attr->flags & (EMB_OBJ_ABORT_WAITERS | EMB_OBJ_FIFO), + attr->name); + embk_wait_queue_init(&c->waiters, ((attr->flags & EMB_OBJ_FIFO) != 0u) ? EMBK_WAIT_FIFO : 0u); +#if CONFIG_EMB_OBJ_LIST + embk_list_append(&embk_cond_all, &c->all_node); + embk_unlock_sched(key); +#endif + out_cond->raw = embk_obj_to_raw(c); + return EMB_OK; +} + +emb_status_t emb_cond_destroy(emb_cond_t cond) +{ + embk_cond_t *c = cond_from_handle(cond); + emb_irq_key_t key; + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_COND_DESTROY); + EMBK_API_REQUIRE(EMB_API_ID_COND_DESTROY, c != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + key = embk_lock_object(c); + if (!embk_wait_queue_is_empty(&c->waiters)) { + if ((c->obj.flags & EMB_OBJ_ABORT_WAITERS) == 0u) { + embk_unlock_object(c, key); + EMBK_API_MISUSE(EMB_API_ID_COND_DESTROY, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); + } + (void)embk_wait_flush(&c->waiters, EMBK_WAKE_DESTROYED); + } +#if CONFIG_EMB_OBJ_LIST + embk_list_remove(&c->all_node); +#endif + c->obj.state = EMBK_OBJ_UNINIT; + embk_sched_reschedule_if_needed(); + embk_unlock_object(c, key); + return EMB_OK; +} + +static emb_status_t wait_common(uint16_t api_id, embk_cond_t *c, embk_mutex_t *m, + emb_tick_t deadline, bool nowait) +{ + embk_thread_t *self; + emb_irq_key_t key; + embk_wait_result_t r; + uintptr_t data; + emb_status_t st; + bool deferred; + EMBK_API_REQUIRE_THREAD(api_id); + EMBK_API_REQUIRE(api_id, c != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + EMBK_API_REQUIRE(api_id, m != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 2u); + EMBK_API_REQUIRE_CAN_BLOCK(api_id, nowait ? 0u : 1u); + self = embk_cpu.current; + key = embk_lock_object(c); + if (m->owner != self) { + embk_unlock_object(c, key); + EMBK_API_MISUSE(api_id, EMB_FAULT_API_OWNER, EMB_EPERM, 2u); + } + if (m->count != 1u) { + EMBK_TRACE(EMB_TRACE_COND_WAIT, embk_thread_index(self), c, EMB_ESTATE); + embk_unlock_object(c, key); + return EMB_ESTATE; /* a recursive hold cannot be released and restored (§6) */ + } + if (embk_thread_take_cancel_locked(self)) { + EMBK_TRACE(EMB_TRACE_COND_WAIT, embk_thread_index(self), c, EMB_ECANCELED); + embk_unlock_object(c, key); + return EMB_ECANCELED; /* the mutex was never released */ + } + if (nowait) { + EMBK_TRACE(EMB_TRACE_COND_WAIT, embk_thread_index(self), c, EMB_ETIMEDOUT); + embk_unlock_object(c, key); + return EMB_ETIMEDOUT; /* no stored signal to find */ + } + embk_wait_prepare(&c->waiters, EMB_WAIT_REASON_OBJECT); + embk_mutex_release_locked(m, self); /* enqueue, then release: atomic for signal */ + embk_unlock_object(c, key); + r = embk_wait_commit(deadline, &data); + /* the mandatory relock is not cancelable: the caller must own the mutex on return */ + deferred = self->cancel_disable < UINT8_MAX; + if (deferred) { + self->cancel_disable++; /* own field, thread context */ + } + st = embk_mutex_lock_common(api_id, m, EMBK_DEADLINE_FOREVER, false); + if (deferred) { + self->cancel_disable--; + } + if (st == EMB_OK) { + st = embk_wait_result_status(r); /* the relock's EOWNERDEAD or failure wins */ + } + EMBK_TRACE(EMB_TRACE_COND_WAIT, embk_thread_index(self), c, st); + return st; +} + +emb_status_t emb_cond_wait(emb_cond_t cond, emb_mutex_t mutex, emb_timeout_t timeout) +{ + return wait_common(EMB_API_ID_COND_WAIT, cond_from_handle(cond), embk_mutex_from_handle(mutex), + embk_deadline_from_timeout(timeout), EMB_TIMEOUT_IS_NO_WAIT(timeout)); +} + +emb_status_t emb_cond_wait_until(emb_cond_t cond, emb_mutex_t mutex, emb_instant_t deadline) +{ + bool nowait = deadline.ticks != EMB_TICK_MAX && embk_deadline_passed(deadline.ticks); + return wait_common(EMB_API_ID_COND_WAIT_UNTIL, cond_from_handle(cond), + embk_mutex_from_handle(mutex), deadline.ticks, nowait); +} + +static emb_status_t wake(uint16_t api_id, emb_cond_t cond, bool all) +{ + embk_cond_t *c = cond_from_handle(cond); + emb_irq_key_t key; + unsigned woken = 0u; + embk_thread_t *w; + EMBK_API_REQUIRE_THREAD(api_id); + EMBK_API_REQUIRE(api_id, c != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + key = embk_lock_object(c); + if (all) { + woken = embk_wait_wake_all(&c->waiters, EMBK_WAKE_SATISFIED, 0u); + } else { + w = embk_wait_first(&c->waiters); + if (w != NULL) { + (void)embk_wait_wake(w, EMBK_WAKE_SATISFIED, 0u); + woken = 1u; + } + } + EMBK_TRACE(EMB_TRACE_COND_SIGNAL, woken, c, all); + (void)woken; /* only the trace reports it */ + embk_sched_reschedule_if_needed(); + embk_unlock_object(c, key); + return EMB_OK; +} + +emb_status_t emb_cond_signal(emb_cond_t cond) +{ + return wake(EMB_API_ID_COND_SIGNAL, cond, false); +} + +emb_status_t emb_cond_broadcast(emb_cond_t cond) +{ + return wake(EMB_API_ID_COND_BROADCAST, cond, true); +} + +#endif /* CONFIG_EMB_COND */ diff --git a/kernel/sync/mutex.c b/kernel/sync/mutex.c index 345f671..4e5d6fb 100644 --- a/kernel/sync/mutex.c +++ b/kernel/sync/mutex.c @@ -248,7 +248,8 @@ static bool chain_reaches(const embk_thread_t *owner, const embk_thread_t *self) return false; } -static emb_status_t lock_common(uint16_t api_id, embk_mutex_t *m, emb_tick_t deadline, bool nowait) +emb_status_t embk_mutex_lock_common(uint16_t api_id, embk_mutex_t *m, emb_tick_t deadline, + bool nowait) { embk_thread_t *self; emb_irq_key_t key; @@ -331,15 +332,16 @@ static emb_status_t lock_common(uint16_t api_id, embk_mutex_t *m, emb_tick_t dea emb_status_t emb_mutex_lock(emb_mutex_t mutex, emb_timeout_t timeout) { - return lock_common(EMB_API_ID_MUTEX_LOCK, mutex_from_handle(mutex), - embk_deadline_from_timeout(timeout), EMB_TIMEOUT_IS_NO_WAIT(timeout)); + return embk_mutex_lock_common(EMB_API_ID_MUTEX_LOCK, mutex_from_handle(mutex), + embk_deadline_from_timeout(timeout), + EMB_TIMEOUT_IS_NO_WAIT(timeout)); } emb_status_t emb_mutex_lock_until(emb_mutex_t mutex, emb_instant_t deadline) { bool nowait = deadline.ticks != EMB_TICK_MAX && embk_deadline_passed(deadline.ticks); - return lock_common(EMB_API_ID_MUTEX_LOCK_UNTIL, mutex_from_handle(mutex), deadline.ticks, - nowait); + return embk_mutex_lock_common(EMB_API_ID_MUTEX_LOCK_UNTIL, mutex_from_handle(mutex), + deadline.ticks, nowait); } /* ---- unlock (§3.3) ------------------------------------------------------------------------ */ @@ -375,6 +377,12 @@ static void hand_over(embk_mutex_t *m, embk_thread_t *from, bool dead) } } +void embk_mutex_release_locked(embk_mutex_t *m, embk_thread_t *self) +{ + hand_over(m, self, false); + embk_prio_propagate(self); /* KRN-SYNC-010: over the still-owned list */ +} + emb_status_t emb_mutex_unlock(emb_mutex_t mutex) { embk_mutex_t *m = mutex_from_handle(mutex); @@ -394,8 +402,7 @@ emb_status_t emb_mutex_unlock(emb_mutex_t mutex) embk_unlock_object(m, key); return EMB_OK; } - hand_over(m, self, false); - embk_prio_propagate(self); /* KRN-SYNC-010: over the still-owned list */ + embk_mutex_release_locked(m, self); embk_sched_reschedule_if_needed(); embk_unlock_object(m, key); return EMB_OK; diff --git a/tests/conformance/CMakeLists.txt b/tests/conformance/CMakeLists.txt index 9558d66..cd18daa 100644 --- a/tests/conformance/CMakeLists.txt +++ b/tests/conformance/CMakeLists.txt @@ -23,6 +23,9 @@ endif() if(CONFIG_EMB_EVENT) emb_add_test(test_event test_event.c) endif() +if(CONFIG_EMB_COND) + emb_add_test(test_cond test_cond.c) +endif() emb_add_test(test_first_execution test_first_execution.c) if(CONFIG_EMB_ARCH_HAS_FPU_LAZY) emb_add_test(test_fpu test_fpu.c) diff --git a/tests/conformance/test_cond.c b/tests/conformance/test_cond.c new file mode 100644 index 0000000..d51c748 --- /dev/null +++ b/tests/conformance/test_cond.c @@ -0,0 +1,162 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* Condition variable conformance (SPEC-005 §6; KRN-SYNC-030, KRN-WAIT-013). */ +#include + +static emb_cond_storage_t cond_storage; +static emb_mutex_storage_t mutex_storage; +static emb_cond_t cv; +static emb_mutex_t mx; + +static void make(uint8_t cond_flags, uint8_t mutex_flags) +{ + emb_cond_attr_t ca; + emb_mutex_attr_t ma; + emb_cond_attr_default(&ca); + ca.flags = cond_flags; + emb_mutex_attr_default(&ma); + ma.flags = mutex_flags; + EMB_ASSERT_OK(emb_cond_init(&cond_storage, &ca, &cv)); + EMB_ASSERT_OK(emb_mutex_init(&mutex_storage, &ma, &mx)); +} + +static void teardown(void) +{ + EMB_ASSERT_OK(emb_cond_destroy(cv)); + EMB_ASSERT_OK(emb_mutex_destroy(mx)); +} + +/* Locks the mutex, waits up to arg ticks (0: forever), marks the status and ownership, unlocks: + * mark = id * 10 + (owner ? 1 : 0) on EMB_OK, + 100 timed out, + 200 canceled, + 300 + * destroyed. */ +static void waiter(void *arg) +{ + uintptr_t a = (uintptr_t)arg; + uint32_t ticks = (uint32_t)(a & 0xFFu); + int id = (int)((a >> 8) & 0xFFu); + emb_status_t st; + int base; + EMB_ASSERT_OK(emb_mutex_lock(mx, EMB_WAIT_FOREVER)); + st = emb_cond_wait(cv, mx, ticks == 0u ? EMB_WAIT_FOREVER : EMB_TIMEOUT(EMB_TICKS(ticks))); + base = (st == EMB_OK) ? 0 + : (st == EMB_ETIMEDOUT) ? 100 + : (st == EMB_ECANCELED) ? 200 + : (st == EMB_EDESTROYED) ? 300 + : 900; + emb_test_mark(base + id * 10 + (emb_mutex_is_owner(mx) ? 1 : 0)); + EMB_ASSERT_OK(emb_mutex_unlock(mx)); +} + +#define WAIT_ARG(ticks, id) ((void *)(uintptr_t)((ticks) | ((id) << 8))) + +EMB_TEST(cond_signal_wakes_one_waiter_which_owns_the_mutex) +{ + EMB_TEST_REQ("KRN-SYNC-030", "KRN-WAIT-001"); + make(0u, 0u); + (void)emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, WAIT_ARG(0u, 3u)); + (void)emb_test_thread(EMB_TEST_NAME("w4"), 4u, 0u, waiter, WAIT_ARG(0u, 4u)); + EMB_ASSERT_OK(emb_cond_signal(cv)); /* the higher waiter */ + EMB_ASSERT_MARKS(41); + EMB_ASSERT_OK(emb_cond_signal(cv)); + EMB_ASSERT_MARKS(41, 31); + EMB_ASSERT_OK(emb_cond_signal(cv)); /* nobody waits: the signal is lost */ + teardown(); +} + +EMB_TEST(cond_broadcast_wakes_all_in_priority_order) +{ + EMB_TEST_REQ("KRN-SYNC-030", "KRN-WAIT-013"); + make(0u, 0u); + (void)emb_test_thread(EMB_TEST_NAME("w2"), 2u, 0u, waiter, WAIT_ARG(0u, 2u)); + (void)emb_test_thread(EMB_TEST_NAME("w4"), 4u, 0u, waiter, WAIT_ARG(0u, 4u)); + (void)emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, WAIT_ARG(0u, 3u)); + EMB_ASSERT_OK(emb_cond_broadcast(cv)); + EMB_ASSERT_MARKS(41, 31, 21); /* each relocks in turn and owns the mutex */ + teardown(); +} + +EMB_TEST(cond_signal_is_not_stored) +{ + uint32_t t0; + EMB_TEST_REQ("KRN-SYNC-030"); + make(0u, 0u); + EMB_ASSERT_OK(emb_cond_signal(cv)); + EMB_ASSERT_OK(emb_cond_broadcast(cv)); + t0 = EMB_TEST_NOW(); + (void)emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, WAIT_ARG(5u, 3u)); + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(10))); + EMB_ASSERT_MARKS(131); /* timed out, owning the mutex */ + EMB_ASSERT_ELAPSED(t0, 10u); + teardown(); +} + +EMB_TEST(cond_relock_after_timeout_waits_for_the_mutex) +{ + EMB_TEST_REQ("KRN-SYNC-030"); + make(0u, 0u); + (void)emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, WAIT_ARG(2u, 3u)); + EMB_ASSERT_OK(emb_mutex_lock(mx, EMB_NO_WAIT)); /* released by the waiting thread */ + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(5))); /* its wait times out meanwhile */ + emb_test_mark(1); + EMB_ASSERT_OK(emb_mutex_unlock(mx)); /* only now can it relock and return */ + EMB_ASSERT_MARKS(1, 131); + teardown(); +} + +EMB_TEST(cond_cancel_returns_with_the_mutex_owned) +{ + emb_thread_t h; + EMB_TEST_REQ("KRN-SYNC-030", "KRN-WAIT-017"); + make(0u, 0u); + h = emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, WAIT_ARG(0u, 3u)); + EMB_ASSERT_OK(emb_thread_cancel(h)); + EMB_ASSERT_MARKS(231); + teardown(); +} + +EMB_TEST(cond_destroy_aborts_waiters_which_relock) +{ + EMB_TEST_REQ("KRN-OBJ-001", "KRN-SYNC-030"); + make(EMB_OBJ_ABORT_WAITERS, 0u); + (void)emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, WAIT_ARG(0u, 3u)); + EMB_ASSERT_OK(emb_cond_destroy(cv)); + EMB_ASSERT_MARKS(331); + EMB_ASSERT_OK(emb_mutex_destroy(mx)); +} + +EMB_TEST(cond_destroy_with_waiters_is_misuse) +{ + EMB_TEST_REQ("KRN-OBJ-001", "API-013"); + make(0u, 0u); + (void)emb_test_thread(EMB_TEST_NAME("w3"), 3u, 0u, waiter, WAIT_ARG(0u, 3u)); + EMB_ASSERT_MISUSE(emb_cond_destroy(cv), EMB_EBUSY); + EMB_ASSERT_OK(emb_cond_signal(cv)); + EMB_ASSERT_MARKS(31); + teardown(); +} + +EMB_TEST(cond_wait_requires_single_ownership) +{ + EMB_TEST_REQ("KRN-SYNC-030", "API-013"); + make(0u, EMB_MUTEX_RECURSIVE); + EMB_ASSERT_MISUSE(emb_cond_wait(cv, mx, EMB_NO_WAIT), EMB_EPERM); /* not the owner */ + EMB_ASSERT_OK(emb_mutex_lock(mx, EMB_NO_WAIT)); + EMB_ASSERT_STATUS(emb_cond_wait(cv, mx, EMB_NO_WAIT), EMB_ETIMEDOUT); /* kept */ + EMB_ASSERT_TRUE(emb_mutex_is_owner(mx)); + EMB_ASSERT_OK(emb_mutex_lock(mx, EMB_NO_WAIT)); /* recursion count 2 */ + EMB_ASSERT_STATUS(emb_cond_wait(cv, mx, EMB_TIMEOUT(EMB_TICKS(1))), EMB_ESTATE); + EMB_ASSERT_OK(emb_mutex_unlock(mx)); + EMB_ASSERT_OK(emb_mutex_unlock(mx)); + teardown(); +} + +#if CONFIG_EMB_OBJ_LIST +EMB_TEST(cond_storage_reuse_requires_destroy) +{ + emb_cond_t other; + EMB_TEST_REQ("KRN-OBJ-007", "KRN-OBJ-011"); + make(0u, 0u); + EMB_ASSERT_MISUSE(emb_cond_init(&cond_storage, NULL, &other), EMB_EBUSY); + teardown(); +} +#endif diff --git a/tests/differential/diff_runner.c b/tests/differential/diff_runner.c index 4aa2e3c..a64fcf2 100644 --- a/tests/differential/diff_runner.c +++ b/tests/differential/diff_runner.c @@ -13,14 +13,16 @@ * S=[,fifo][,bind=.] a semaphore * M=,, a mutex (protocol 0 inherit, 1 ceiling, 2 none) * E=[,fifo][,bind=.] event flags + * C= a condition variable (fifo 0 or 1) * I:;... an ISR body (give, resume, nset, start, eset, eclear) * Operations (t: -1 forever, 0 no wait, n ticks): * take s t | give s | lock m t | unlock m | nset tid bits | nwait mask all clear t | * sleep t | yield | setprio tid prio | suspend tid | resume tid | cancel tid | destroy s | - * slock | sunlock | exit code | start tid | join tid t | bind s tid bit | unbind s | tdestroy tid - * | eset e bits | eclear e bits | ewait e mask all clear t | edestroy e A schedule entry fires - * right after the n-th trace event (or in idle, if that event never comes): "isr@n" raises ISR - * i, "tick@n" advances virtual time by one tick. + * slock | sunlock | exit code | start tid | join tid t | bind s tid bit | unbind s | + * tdestroy tid | eset e bits | eclear e bits | ewait e mask all clear t | edestroy e | + * cwait c m t | csignal c | cbcast c | cdestroy c + * A schedule entry fires right after the n-th trace event (or in idle, if that event never + * comes): "isr@n" raises ISR i, "tick@n" advances virtual time by one tick. */ #include @@ -40,6 +42,7 @@ #define MAX_SEMS 4u #define MAX_MUTEXES 4u #define MAX_EVFLAGS 4u +#define MAX_CONDS 4u #define MAX_ISRS 4u #define MAX_EVENTS 16384u #define MAX_SCHED 64u @@ -76,7 +79,11 @@ enum op_kind { OP_ESET, OP_ECLEAR, OP_EWAIT, - OP_EDESTROY + OP_EDESTROY, + OP_CWAIT, + OP_CSIGNAL, + OP_CBCAST, + OP_CDESTROY }; struct op { @@ -127,6 +134,15 @@ struct evflag_desc { #endif }; +struct cond_desc { + long cid; + bool fifo; +#if CONFIG_EMB_COND + emb_cond_storage_t storage; + emb_cond_t handle; +#endif +}; + struct mutex_desc { long mid; long protocol; @@ -157,6 +173,8 @@ static struct sem_desc sems[MAX_SEMS]; static unsigned sem_count; static struct evflag_desc evflags[MAX_EVFLAGS]; static unsigned evflag_count; +static struct cond_desc conds[MAX_CONDS]; +static unsigned cond_count; static struct mutex_desc mutexes[MAX_MUTEXES]; static unsigned mutex_count; static struct program isrs[MAX_ISRS]; @@ -314,6 +332,15 @@ static void parse_op(char *text, struct op *op) } else if (strcmp(name, "edestroy") == 0) { op->kind = OP_EDESTROY; op->a = num(args[0]); + } else if (strcmp(name, "cwait") == 0) { + op->kind = OP_CWAIT; + op->a = num(args[0]); + op->b = num(args[1]); + op->c = num(args[2]); + } else if (strcmp(name, "csignal") == 0 || strcmp(name, "cbcast") == 0 || + strcmp(name, "cdestroy") == 0) { + op->kind = (name[1] == 's') ? OP_CSIGNAL : (name[1] == 'b') ? OP_CBCAST : OP_CDESTROY; + op->a = num(args[0]); } else { die("unknown operation"); } @@ -396,6 +423,14 @@ static void parse_section(char *sec) die("bad event flag attribute"); } } + } else if (kind == 'C') { + struct cond_desc *c; + if (cond_count >= MAX_CONDS) { + die("too many condition variables"); + } + c = &conds[cond_count++]; + c->cid = num(cut(&p, '=')); + c->fifo = num(p) != 0; } else if (kind == 'M') { struct mutex_desc *m; char *attrs; @@ -492,6 +527,20 @@ static emb_event_t evflag_by_eid(long eid) } #endif +#if CONFIG_EMB_COND +static emb_cond_t cond_by_cid(long cid) +{ + unsigned i; + for (i = 0u; i < cond_count; i++) { + if (conds[i].cid == cid) { + return conds[i].handle; + } + } + die("unknown condition variable id"); + return conds[0].handle; +} +#endif + static emb_mutex_t mutex_by_mid(long mid) { unsigned i; @@ -707,6 +756,31 @@ static void run_ops(const struct program *prog, struct thread_desc *self) case OP_EDESTROY: die("event flags are not configured"); break; +#endif +#if CONFIG_EMB_COND + case OP_CWAIT: + st = emb_cond_wait(cond_by_cid(op->a), mutex_by_mid(op->b), timeout_of(op->c)); + if (st == EMB_EOWNERDEAD) { + data = 2u; /* the model's OWNERDEAD_FLAG, as for lock */ + st = EMB_OK; + } + break; + case OP_CSIGNAL: + st = emb_cond_signal(cond_by_cid(op->a)); + break; + case OP_CBCAST: + st = emb_cond_broadcast(cond_by_cid(op->a)); + break; + case OP_CDESTROY: + st = emb_cond_destroy(cond_by_cid(op->a)); + break; +#else + case OP_CWAIT: + case OP_CSIGNAL: + case OP_CBCAST: + case OP_CDESTROY: + die("condition variables are not configured"); + break; #endif default: die("bad operation kind"); @@ -742,15 +816,16 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) return; } dumped = 1u; - (void)printf( - "config profile=%s owner_death=%s checked=%d tickless=%d idle_index=254 events=%d\n", - CONFIG_EMB_PROFILE, + (void)printf("config profile=%s owner_death=%s checked=%d tickless=%d idle_index=254 events=%d " + "conds=%d\n", + CONFIG_EMB_PROFILE, #if CONFIG_EMB_MUTEX_OWNER_DEATH_FAULT && CONFIG_EMB_CHECKED - "fault", + "fault", #else - "release", + "release", #endif - (int)CONFIG_EMB_CHECKED, (int)CONFIG_EMB_TICKLESS, (int)CONFIG_EMB_EVENT); + (int)CONFIG_EMB_CHECKED, (int)CONFIG_EMB_TICKLESS, (int)CONFIG_EMB_EVENT, + (int)CONFIG_EMB_COND); n = event_count; for (i = 0u; i < n; i++) { (void)printf("ev %u %u %lu %lu %lu %lu\n", (unsigned)events[i].id, @@ -854,6 +929,18 @@ static void construct(void) if (evflag_count != 0u) { die("event flags are not configured"); } +#endif +#if CONFIG_EMB_COND + for (i = 0u; i < cond_count; i++) { + emb_cond_attr_t attr; + emb_cond_attr_default(&attr); + attr.flags = (uint8_t)(EMB_OBJ_ABORT_WAITERS | (conds[i].fifo ? EMB_OBJ_FIFO : 0u)); + EMB_CHECK(emb_cond_init(&conds[i].storage, &attr, &conds[i].handle)); + } +#else + if (cond_count != 0u) { + die("condition variables are not configured"); + } #endif for (i = 0u; i < mutex_count; i++) { emb_mutex_attr_t attr; diff --git a/tools/fault/api-ids.json b/tools/fault/api-ids.json index 3230734..fdbb159 100644 --- a/tools/fault/api-ids.json +++ b/tools/fault/api-ids.json @@ -565,6 +565,48 @@ "name": "emb_event_unbind_notify", "header": "event.h", "state": "active" + }, + { + "id": 95, + "name": "emb_cond_attr_default", + "header": "cond.h", + "state": "active" + }, + { + "id": 96, + "name": "emb_cond_init", + "header": "cond.h", + "state": "active" + }, + { + "id": 97, + "name": "emb_cond_destroy", + "header": "cond.h", + "state": "active" + }, + { + "id": 98, + "name": "emb_cond_wait", + "header": "cond.h", + "state": "active" + }, + { + "id": 99, + "name": "emb_cond_wait_until", + "header": "cond.h", + "state": "active" + }, + { + "id": 100, + "name": "emb_cond_signal", + "header": "cond.h", + "state": "active" + }, + { + "id": 101, + "name": "emb_cond_broadcast", + "header": "cond.h", + "state": "active" } ] } diff --git a/tools/fault/api-registry.json b/tools/fault/api-registry.json index 7e309b4..9547825 100644 --- a/tools/fault/api-registry.json +++ b/tools/fault/api-registry.json @@ -564,6 +564,48 @@ "name": "emb_event_unbind_notify", "header": "event.h", "state": "active" + }, + { + "id": 95, + "name": "emb_cond_attr_default", + "header": "cond.h", + "state": "active" + }, + { + "id": 96, + "name": "emb_cond_init", + "header": "cond.h", + "state": "active" + }, + { + "id": 97, + "name": "emb_cond_destroy", + "header": "cond.h", + "state": "active" + }, + { + "id": 98, + "name": "emb_cond_wait", + "header": "cond.h", + "state": "active" + }, + { + "id": 99, + "name": "emb_cond_wait_until", + "header": "cond.h", + "state": "active" + }, + { + "id": 100, + "name": "emb_cond_signal", + "header": "cond.h", + "state": "active" + }, + { + "id": 101, + "name": "emb_cond_broadcast", + "header": "cond.h", + "state": "active" } ] } diff --git a/tools/model/bridge.py b/tools/model/bridge.py index d7e0abb..38a3fa7 100644 --- a/tools/model/bridge.py +++ b/tools/model/bridge.py @@ -28,7 +28,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from embmodel import (BindNotify, Cancel, Destroy, EventClear, EventDestroy, EventSet, # noqa: E402 +from embmodel import (BindNotify, Cancel, CondBroadcast, CondDestroy, CondSignal, CondWait, # noqa: E402 + Destroy, EventClear, EventDestroy, EventSet, EventWait, Exit, Give, Join, Lock, ModelError, NONWAIT_RESULT_OPS, NotifySet, NotifyWait, Policy, Reason, Result, Resume, Scenario, SchedLock, SchedUnlock, SetPrio, Sleep, Start, Suspend, Take, ThreadDestroy, @@ -42,6 +43,7 @@ TR_MUTEX_LOCK, TR_MUTEX_UNLOCK, TR_SEM_TAKE, TR_SEM_GIVE = 16, 17, 18, 19 TR_NOTIFY_SET, TR_NOTIFY_WAIT, TR_FAULT, TR_TICK = 20, 21, 25, 29 TR_EVENT_SET, TR_EVENT_WAIT = 31, 32 +TR_COND_WAIT, TR_COND_SIGNAL = 33, 34 EV_ISR_MARK = 1000 # the runner's own record: ISR body i starts EV_INDEX_MARK = 1001 # the runner's record: thread tid now has index i IDLE_INDEX = 0xFE @@ -50,7 +52,8 @@ # emb_status_t -> Result (status.h; the model's EPERM stands in for EBUSY at a second joiner) STATUS_TO_RESULT = {0: Result.SATISFIED, -4: Result.TIMEOUT, -5: Result.CANCELED, -6: Result.DESTROYED, -16: Result.STALE, -32: Result.DEADLOCK, -1: Result.EPERM, -3: Result.EPERM, - -2: Result.STALE} # EINVAL on a wait: the pointer handle of a destroyed object + -2: Result.STALE, # EINVAL on a wait: the pointer handle of a destroyed object + -14: Result.STATE} # ESTATE: a condition wait on a recursively held mutex # emb_status_t -> Result for the non-wait operations whose status is compared (M2-03 binding lifetime) NONWAIT_STATUS_TO_RESULT = {0: Result.SATISFIED, -1: Result.EPERM, -2: Result.STALE, -3: Result.BUSY, -12: Result.NOENT, -13: Result.EXIST, -14: Result.STATE} @@ -143,6 +146,14 @@ def encode_op(op) -> str: return f"ewait {op.event} {op.mask} {int(op.all)} {int(op.clear)} {_t(op.timeout)}" if isinstance(op, EventDestroy): return f"edestroy {op.event}" + if isinstance(op, CondWait): + return f"cwait {op.cond} {op.mutex} {_t(op.timeout)}" + if isinstance(op, CondSignal): + return f"csignal {op.cond}" + if isinstance(op, CondBroadcast): + return f"cbcast {op.cond}" + if isinstance(op, CondDestroy): + return f"cdestroy {op.cond}" raise ValueError(f"unknown op {op!r}") @@ -159,6 +170,8 @@ def encode_scenario(sc: Scenario) -> str: tid, bit = cfg["binding"] attrs.append(f"bind={tid}.{bit}") parts.append(f"S{sid}=" + ",".join(attrs)) + for cid, cfg in sc.conds.items(): + parts.append(f"C{cid}={int(cfg.get('policy') == Policy.FIFO)}") for eid, cfg in sc.events.items(): attrs = [str(cfg.get("bits", 0))] if cfg.get("policy") == Policy.FIFO: @@ -291,7 +304,7 @@ def thr(idx): out.append(("wait_begin", thr(a0), KREASON_TO_REASON[a1], ("q", a2))) elif eid == TR_WAKE: out.append(("wake", thr(a0), KWAKE_TO_RESULT[a1])) - elif eid in (TR_SEM_TAKE, TR_NOTIFY_WAIT, TR_MUTEX_LOCK, TR_EVENT_WAIT): + elif eid in (TR_SEM_TAKE, TR_NOTIFY_WAIT, TR_MUTEX_LOCK, TR_EVENT_WAIT, TR_COND_WAIT): st = a1 if eid == TR_NOTIFY_WAIT else a2 st = st - (1 << 64) if st >= (1 << 63) else st # printed as unsigned long res = Result.SATISFIED if st == -7 else STATUS_TO_RESULT.get(st) @@ -562,7 +575,8 @@ def finish(self, k, path): kres_mapped = Result.TIMEOUT # a completed sleep is EMB_OK in the kernel if kres_mapped != result: raise Divergence(f"thread {tid} op {op_index} {op!r}: model {result.name}, kernel status {kstatus}") - if isinstance(op, (NotifyWait, Join, Lock, EventWait)) and result == Result.SATISFIED and kdata != data: + if isinstance(op, (NotifyWait, Join, Lock, EventWait, CondWait)) and result == Result.SATISFIED \ + and kdata != data: raise Divergence(f"thread {tid} op {op_index} {op!r}: model data {data}, kernel data {kdata}") kstate = r.states.get(tid) if t.destroyed: @@ -637,10 +651,13 @@ def applicable(sc: Scenario, config: dict) -> tuple: prios = [p for p, _ in sc.threads.values()] if sc.events and config.get("events", "0") != "1": return False, "event flags are not configured in this build" + if sc.conds and config.get("conds", "0") != "1": + return False, "condition variables are not configured in this build" if profile == "tiny": if len(set(prios)) != len(prios): return False, "shared priorities need the base profile" - if any(cfg.get("policy") == Policy.FIFO for cfg in list(sc.sems.values()) + list(sc.events.values())): + if any(cfg.get("policy") == Policy.FIFO + for cfg in list(sc.sems.values()) + list(sc.events.values()) + list(sc.conds.values())): return False, "FIFO queues need the base profile" elif bitmap: return False, "BITMAP queues need the tiny profile" @@ -652,7 +669,8 @@ def adapt(sc: Scenario, config: dict) -> Scenario: if config.get("profile") == "tiny": sems = {sid: dict(cfg, policy=Policy.BITMAP) for sid, cfg in sc.sems.items()} events = {eid: dict(cfg, policy=Policy.BITMAP) for eid, cfg in sc.events.items()} - sc = replace(sc, sems=sems, events=events) + conds = {cid: dict(cfg, policy=Policy.BITMAP) for cid, cfg in sc.conds.items()} + sc = replace(sc, sems=sems, events=events, conds=conds) # Historical native captures predate the clock-mode field and used tickless builds. return replace(sc, owner_death=config.get("owner_death", "release"), tickless=config.get("tickless", "1") == "1") diff --git a/tools/model/embmodel/__init__.py b/tools/model/embmodel/__init__.py index c4e4740..da686f0 100644 --- a/tools/model/embmodel/__init__.py +++ b/tools/model/embmodel/__init__.py @@ -9,7 +9,7 @@ granularity and checks the invariants of SPEC-004 §5.4 after every step. """ from .kernel import ( # noqa: F401 - BindNotify, Cancel, Destroy, EventClear, EventDestroy, EventSet, EventWait, Exit, Give, Join, + BindNotify, Cancel, CondBroadcast, CondDestroy, CondSignal, CondWait, Destroy, EventClear, EventDestroy, EventSet, EventWait, Exit, Give, Join, NONWAIT_RESULT_OPS, ThreadDestroy, UnbindNotify, Kernel, Lock, ModelError, Mutex, NotifySet, NotifyWait, Policy, Protocol, Reason, Result, Resume, Start, SchedLock, SchedUnlock, Scenario, SetPrio, Sleep, Suspend, Take, Thread, Unlock, WaitState, Yield, FOREVER, NO_WAIT, OWNERDEAD_FLAG, PI_MAX_DEPTH, build, diff --git a/tools/model/embmodel/kernel.py b/tools/model/embmodel/kernel.py index 9a32081..0267361 100644 --- a/tools/model/embmodel/kernel.py +++ b/tools/model/embmodel/kernel.py @@ -64,6 +64,7 @@ class Protocol(IntEnum): # SPEC-005 §3 PI_MAX_DEPTH = 8 # CONFIG_EMB_PI_MAX_DEPTH default MUTEX_QID_BASE = 100 # queue ids: semaphores 0..99, mutexes 100.., thread pseudo-queues negative EVENT_QID_BASE = 200 # event flags 200.. +COND_QID_BASE = 300 # condition variables 300.. class ModelError(AssertionError): @@ -229,6 +230,30 @@ class EventDestroy: event: int +@dataclass(frozen=True) +class CondWait: + """emb_cond_wait(cond, mutex, timeout): release the mutex with the enqueue, wait, relock (SPEC-005 §6).""" + cond: int + mutex: int + timeout: Optional[int] = FOREVER + + +@dataclass(frozen=True) +class CondSignal: + cond: int + + +@dataclass(frozen=True) +class CondBroadcast: + cond: int + + +@dataclass(frozen=True) +class CondDestroy: + """emb_cond_destroy(cond) with EMB_OBJ_ABORT_WAITERS: waiters leave with DESTROYED, after relocking.""" + cond: int + + def event_satisfying(bits: int, mask: int, all_: bool) -> int: got = bits & mask if all_: @@ -272,6 +297,10 @@ class Thread: joiner: Optional[int] = None # the one joiner's tid, while blocked (SPEC-008 §6) collected: bool = False # the exit code was joined: destroy is allowed (SPEC-008 §8) destroyed: bool = False # emb_thread_destroy succeeded; no handle names it any more + cond_phase: int = 0 # CondWait: 0 waiting, 1 relocking, 2 relocked (reporting) + cond_result: Optional[Result] = None # the condition wait's result, then the final one + cond_data: int = 0 # the final result's data (OWNERDEAD_FLAG) + cancel_deferred: bool = False # cancellation is not delivered (the relock, A41) def key(self): return (self.tid, self.prio, self.base_prio, int(self.wait_state), self.wait_gen, @@ -280,7 +309,9 @@ def key(self): self.suspended, self.cancel_pending, self.terminated, self.op_index, self.pc, self.saved_gen, self.deadline, self.results, self.notify_bits, self.notify_mask, self.notify_all, self.notify_clear, tuple(self.owned), self.inactive, self.exit_code, self.joiner, - self.collected, self.destroyed) + self.collected, self.destroyed, self.cond_phase, + None if self.cond_result is None else int(self.cond_result), self.cond_data, + self.cancel_deferred) @dataclass @@ -323,6 +354,16 @@ def key(self): return (self.eid, self.bits, self.qid, self.destroyed, self.binding) +@dataclass +class Cond: + cid: int + qid: int + destroyed: bool = False + + def key(self): + return (self.cid, self.qid, self.destroyed) + + @dataclass class Mutex: mid: int @@ -346,6 +387,7 @@ class Scenario: sems: dict = field(default_factory=dict) # sid -> dict(count=, policy=, binding=) mutexes: dict = field(default_factory=dict) # mid -> dict(protocol=, ceiling=, recursive=) events: dict = field(default_factory=dict) # eid -> dict(bits=, policy=, binding=) + conds: dict = field(default_factory=dict) # cid -> dict(policy=) isrs: tuple = () owner_death: str = "release" # CONFIG_EMB_MUTEX_OWNER_DEATH: "release" | "fault" inactive: tuple = () # tids created INACTIVE (must be started by another thread or an ISR) @@ -367,6 +409,10 @@ def build(sc: Scenario) -> "Kernel": k.queues[sid] = WaitQueue(qid=sid, policy=cfg.get("policy", Policy.PRIORITY_FIFO)) k.sems[sid] = Semaphore(sid=sid, count=cfg.get("count", 0), initial=cfg.get("count", 0), qid=sid, binding=cfg.get("binding")) + for cid, cfg in sc.conds.items(): + qid = COND_QID_BASE + cid + k.queues[qid] = WaitQueue(qid=qid, policy=cfg.get("policy", Policy.PRIORITY_FIFO)) + k.conds[cid] = Cond(cid=cid, qid=qid) for eid, cfg in sc.events.items(): qid = EVENT_QID_BASE + eid k.queues[qid] = WaitQueue(qid=qid, policy=cfg.get("policy", Policy.PRIORITY_FIFO)) @@ -406,6 +452,7 @@ class Kernel: sems: dict = field(default_factory=dict) mutexes: dict = field(default_factory=dict) events: dict = field(default_factory=dict) + conds: dict = field(default_factory=dict) qid_mutex: dict = field(default_factory=dict) # static map, not part of the key ready: dict = field(default_factory=dict) current: Optional[int] = None @@ -429,6 +476,7 @@ def key(self): tuple(s.key() for _, s in sorted(self.sems.items())), tuple(m.key() for _, m in sorted(self.mutexes.items())), tuple(e.key() for _, e in sorted(self.events.items())), + tuple(c.key() for _, c in sorted(self.conds.items())), tuple((p, tuple(v)) for p, v in sorted(self.ready.items()) if v), self.current, self.now, self.irq_nesting, self.sched_lock_depth, self.reschedule_pending, tuple(self.timeouts), self.timer_pending, self.dequeued, @@ -443,6 +491,7 @@ def clone(self) -> "Kernel": k.sems = {i: copy.copy(s) for i, s in self.sems.items()} k.mutexes = {i: copy.copy(m) for i, m in self.mutexes.items()} k.events = {i: copy.copy(e) for i, e in self.events.items()} + k.conds = {i: copy.copy(c) for i, c in self.conds.items()} k.ready = {p: list(v) for p, v in self.ready.items()} k.timeouts = list(self.timeouts) k.trace = [] @@ -694,31 +743,34 @@ def _advance(self, t: Thread): t.pc = 0 def _block_section(self, t: Thread, q: WaitQueue, reason: Reason, timeout: Optional[int], - satisfied, consume, pre_nowait=None, pre_block=None, on_enqueue=None) -> bool: + satisfied, consume, pre_nowait=None, pre_block=None, on_enqueue=None, + finish=None) -> bool: """Sections 1 to 3 of SPEC-004 §5.1 driven by `t.pc`. `pre_nowait` runs before the EMB_NO_WAIT check and `pre_block` after it; either may return a Result to finish with (SPEC-005 §3.2: self-relock, then cycle detection). `on_enqueue` runs after the enqueue - inside section 1 (the inheritance walk). Returns True when the op completed.""" + inside section 1 (the inheritance walk). `finish` replaces _finish (a condition wait's + two phases). Returns True when the op completed.""" + finish = finish or self._finish if t.pc == 0: - if t.cancel_pending: + if t.cancel_pending and not t.cancel_deferred: t.cancel_pending = False - self._finish(t, Result.CANCELED) + finish(t, Result.CANCELED) return True if satisfied(): - self._finish(t, Result.SATISFIED, consume()) + finish(t, Result.SATISFIED, consume()) return True if pre_nowait is not None: r = pre_nowait() if r is not None: - self._finish(t, r) + finish(t, r) return True if timeout == NO_WAIT: - self._finish(t, Result.TIMEOUT) + finish(t, Result.TIMEOUT) return True if pre_block is not None: r = pre_block() if r is not None: - self._finish(t, r) + finish(t, r) return True if self.irq_nesting > 0 or self.sched_lock_depth > 0: raise ModelError("blocking with a nonzero timeout from ISR or under the scheduler lock (SPEC-001 §5.3)") @@ -730,10 +782,10 @@ def _block_section(self, t: Thread, q: WaitQueue, reason: Reason, timeout: Optio t.wait_queue = q.qid self._enqueue(q, t) t.wait_state = WaitState.INTEND_TO_BLOCK + self._event("wait_begin", t.tid, q.qid, int(reason)) # traced by embk_wait_prepare() if on_enqueue is not None: - on_enqueue() + on_enqueue() # after it, in the same section t.pc = 1 - self._event("wait_begin", t.tid, q.qid, int(reason)) return False if t.pc == 1: if t.deadline is not None: @@ -755,7 +807,7 @@ def _block_section(self, t: Thread, q: WaitQueue, reason: Reason, timeout: Optio t.pc = 3 if t.wait_state != WaitState.READY or t.wake_result is None: raise ModelError(f"thread {t.tid} resumed without a result") - self._finish(t, t.wake_result, t.wake_data) + finish(t, t.wake_result, t.wake_data) return True # ------------------------------------------------------------------ semaphore (SPEC-005 §4) @@ -836,9 +888,10 @@ def _acquire(self, t: Thread, m: Mutex) -> int: self._event("mutex_lock", t.tid, m.mid) return OWNERDEAD_FLAG if m.inconsistent else 0 - def _op_lock(self, t: Thread, op: Lock) -> bool: + def _op_lock(self, t: Thread, op: Lock, finish=None) -> bool: m = self.mutexes[op.mutex] q = self.queues[m.qid] + finish = finish or self._finish def satisfied(): return m.owner is None or (m.owner == t.tid and m.recursive) @@ -866,10 +919,10 @@ def on_enqueue(): self.propagate(self.threads[m.owner]) # raise the owner's chain if t.pc == 0 and m.owner is None and m.protocol == Protocol.CEILING and t.base_prio > m.ceiling: - self._finish(t, Result.EPERM) # ceiling violation (release build) + finish(t, Result.EPERM) # ceiling violation (release build) return True return self._block_section(t, q, Reason.OBJECT, op.timeout, satisfied, consume, - pre_nowait, pre_block, on_enqueue) + pre_nowait, pre_block, on_enqueue, finish) def _release(self, t: Thread, m: Mutex, dead: bool): """Give `m` up on behalf of `t`: hand-off to the highest waiter or free it (SPEC-005 §3.3, §3.6).""" @@ -999,7 +1052,8 @@ def _op_cancel(self, op: Cancel): t = self.threads[op.tid] if t.terminated: return - if t.wait_state != WaitState.READY and t.wait_reason in CANCELABLE and t.wait_queue is not None: + if t.wait_state != WaitState.READY and t.wait_reason in CANCELABLE and t.wait_queue is not None \ + and not t.cancel_deferred: self.wake(t, Result.CANCELED, 0, self.queues[t.wait_queue]) return t.cancel_pending = True @@ -1065,6 +1119,73 @@ def consume(): return got return self._block_section(t, self.queues[e.qid], Reason.OBJECT, op.timeout, satisfied, consume) + # ------------------------------------------------------------------ condition variables (SPEC-005 §6, M2-06) + def _op_cond_wait(self, t: Thread, op: CondWait) -> bool: + c, m = self.conds[op.cond], self.mutexes[op.mutex] + if t.cond_phase == 0: + if t.pc == 0: + if c.destroyed: + self._finish(t, Result.STALE) + return True + if m.owner != t.tid: + self._finish(t, Result.EPERM) # misuse: a fault when checked + return True + if m.count != 1: + self._finish(t, Result.STATE) # a recursive hold + return True + if t.cancel_pending: + t.cancel_pending = False + self._finish(t, Result.CANCELED) # the mutex was never released + return True + if op.timeout == NO_WAIT: + self._finish(t, Result.TIMEOUT) # no stored signal; the mutex is kept + return True + + def release(): + self._release(t, m, dead=False) # the same section as the enqueue + + def to_relock(tt, result, data=0): + t.cond_result, t.cond_phase, t.pc = result, 1, 0 + t.wait_reason, t.wake_result, t.wake_data = None, None, 0 + self._block_section(t, self.queues[c.qid], Reason.OBJECT, op.timeout, lambda: False, lambda: 0, + on_enqueue=release, finish=to_relock) + return False + if t.cond_phase == 2: # the report follows the lock section + result, data = t.cond_result, t.cond_data + t.cond_result, t.cond_data, t.cond_phase = None, 0, 0 + self._finish(t, result, data) + return True + t.cancel_deferred = True + + def relocked(tt, result, data=0): + t.cancel_deferred = False + self._event("wait_end", t.tid, int(result), "Lock") # the relock's own record + if result == Result.SATISFIED and m.owner != t.tid: + raise ModelError(f"thread {t.tid} returns from a condition wait without its mutex") + if result == Result.SATISFIED and data & OWNERDEAD_FLAG: + t.cond_result, t.cond_data = Result.SATISFIED, OWNERDEAD_FLAG # EMB_EOWNERDEAD wins + elif result != Result.SATISFIED: + t.cond_result = result # the relock failed: no mutex + t.cond_phase, t.pc = 2, 0 + t.wait_reason, t.wake_result, t.wake_data = None, None, 0 + return self._op_lock(t, Lock(op.mutex, FOREVER), finish=relocked) + + def _op_cond_wake(self, op): + c = self.conds[op.cond] + if c.destroyed: + return + q = self.queues[c.qid] + if isinstance(op, CondDestroy): + self.wake_all(q, Result.DESTROYED) + c.destroyed = True + elif isinstance(op, CondBroadcast): + while q.waiters: + self.wake(self.threads[q.waiters[0][0]], Result.SATISFIED, 0, q, handoff=True) + else: + w = self.wait_first(q) + if w is not None: + self.wake(w, Result.SATISFIED, 0, q, handoff=True) + def _op_event_destroy(self, op: EventDestroy): e = self.events[op.event] if e.destroyed: @@ -1162,6 +1283,12 @@ def step_thread(self) -> bool: self.reschedule_if_needed() elif isinstance(op, EventWait): self._op_event_wait(t, op) + elif isinstance(op, CondWait): + self._op_cond_wait(t, op) + elif isinstance(op, (CondSignal, CondBroadcast, CondDestroy)): + self._op_cond_wake(op) + self._advance(t) + self.reschedule_if_needed() elif isinstance(op, (EventSet, EventClear, EventDestroy)): if isinstance(op, EventSet): self._op_event_set(op) diff --git a/tools/model/evidence.py b/tools/model/evidence.py index 0f6879a..46da50e 100644 --- a/tools/model/evidence.py +++ b/tools/model/evidence.py @@ -25,7 +25,8 @@ bridge.Suspend, bridge.Resume, bridge.Cancel, bridge.Destroy, bridge.SchedLock, bridge.SchedUnlock, bridge.Exit, bridge.Start, bridge.Join, bridge.BindNotify, bridge.UnbindNotify, bridge.ThreadDestroy, - bridge.EventSet, bridge.EventClear, bridge.EventWait, bridge.EventDestroy)} + bridge.EventSet, bridge.EventClear, bridge.EventWait, bridge.EventDestroy, + bridge.CondWait, bridge.CondSignal, bridge.CondBroadcast, bridge.CondDestroy)} def scenario_data(sc): @@ -38,6 +39,8 @@ def program(ops): owner_death=sc.owner_death, tickless=sc.tickless) if sc.events: # absent from bundles that predate event flags data["events"] = list(sc.events.items()) + if sc.conds: # and condition variables + data["conds"] = list(sc.conds.items()) return data @@ -79,6 +82,7 @@ def objects(items, enum_field=None, enum_type=None): sems=objects(raw["sems"], "policy", bridge.Policy), mutexes=objects(raw["mutexes"], "protocol", Protocol), events=objects(raw.get("events", []), "policy", bridge.Policy), + conds=objects(raw.get("conds", []), "policy", bridge.Policy), isrs=tuple(program(ops) for ops in raw["isrs"]), inactive=tuple(raw["inactive"]), owner_death=raw["owner_death"], tickless=raw["tickless"]) diff --git a/tools/model/tests/scenarios.py b/tools/model/tests/scenarios.py index 9c886f2..9dab95d 100644 --- a/tools/model/tests/scenarios.py +++ b/tools/model/tests/scenarios.py @@ -469,3 +469,54 @@ def event_timeout_then_set(): EVENT = [event_overlapping_clear, event_all_and_any, event_isr_set_vs_timed_wait, event_destroy_and_cancel_waiters, event_binding_after_direct_waiters, event_timeout_then_set] ALL = ALL + EVENT + + +# --------------------------------------------------------------------------------------- +# M2-06: condition variables (SPEC-005 §6) +# --------------------------------------------------------------------------------------- +from embmodel import CondBroadcast, CondDestroy, CondSignal, CondWait # noqa: E402 + + +def cond_signal_not_lost(): + """The release and the enqueue are one section: the signal after it always finds the waiter.""" + return Scenario(name="cond_signal_not_lost", + threads={1: (2, (Lock(0), CondWait(0, 0), Unlock(0))), + 2: (1, (Lock(0), CondSignal(0), Unlock(0)))}, + mutexes={0: {}}, conds={0: {}}) + + +def cond_timeout_relocks(): + """The wait times out while another thread holds the mutex; the relock waits for its unlock.""" + return Scenario(name="cond_timeout_relocks", + threads={1: (2, (Lock(0), CondWait(0, 0, timeout=2), Unlock(0))), + 2: (1, (Lock(0), Sleep(4), Unlock(0)))}, + mutexes={0: {}}, conds={0: {}}) + + +def cond_cancel_during_relock(): + """A cancel sent while the signaled waiter relocks is deferred: the wait still reports the signal.""" + return Scenario(name="cond_cancel_during_relock", + threads={1: (3, (Lock(0), CondWait(0, 0), Unlock(0))), + 2: (2, (Lock(0), CondSignal(0), Cancel(1), Unlock(0)))}, + mutexes={0: {}}, conds={0: {}}) + + +def cond_broadcast_wakes_all(): + """A broadcast wakes both waiters; each returns owning the mutex in turn.""" + return Scenario(name="cond_broadcast_wakes_all", + threads={1: (4, (Lock(0), CondWait(0, 0), Unlock(0))), + 2: (3, (Lock(0), CondWait(0, 0), Unlock(0))), + 3: (1, (Lock(0), CondBroadcast(0), Unlock(0)))}, + mutexes={0: {}}, conds={0: {}}) + + +def cond_destroy_aborts_waiter(): + """Destroying the condition variable aborts its waiter, which still relocks the mutex.""" + return Scenario(name="cond_destroy_aborts_waiter", + threads={1: (3, (Lock(0), CondWait(0, 0), Unlock(0))), 2: (2, (CondDestroy(0),))}, + mutexes={0: {}}, conds={0: {}}) + + +COND = [cond_signal_not_lost, cond_timeout_relocks, cond_cancel_during_relock, cond_broadcast_wakes_all, + cond_destroy_aborts_waiter] +ALL = ALL + COND diff --git a/tools/model/tests/test_explore.py b/tools/model/tests/test_explore.py index 2bebc19..251e81a 100644 --- a/tools/model/tests/test_explore.py +++ b/tools/model/tests/test_explore.py @@ -322,3 +322,42 @@ def test_timeout_leaves_the_later_bit_set(self): for d, ev in self.outcomes(S.event_timeout_then_set): self.assertEqual(d[1][0][1], Result.TIMEOUT) self.assertEqual(ev[0], 1) + + +class ConditionVariables(unittest.TestCase): + """M2-06 (SPEC-005 §6): no lost signal, mandatory relock, deferred cancel, broadcast, destroy.""" + + @staticmethod + def cond_results(make): + out = [] + for o in explore(make()).outcomes: + d = dict(dict(o)["threads"]) + out.append({tid: [r for r in res if r[0] == 1] for tid, res in d.items()}) # op 1 is the CondWait + return out + + def test_every_cond_scenario_holds_invariants(self): + for make in S.COND: + sc = make() + with self.subTest(scenario=sc.name): + rep = explore(sc) + self.assertGreater(rep.terminals, 0) + for o in rep.outcomes: + self.assertEqual(dict(dict(o)["mutex_owners"])[0], None) # every caller unlocked again + + def test_signal_after_release_is_never_lost(self): + for d in self.cond_results(S.cond_signal_not_lost): + self.assertEqual(d[1][0][1], Result.SATISFIED) + + def test_timeout_still_returns_with_the_mutex(self): + for d in self.cond_results(S.cond_timeout_relocks): + self.assertEqual(d[1][0][1], Result.TIMEOUT) + + def test_cancel_during_relock_is_deferred(self): + for d in self.cond_results(S.cond_cancel_during_relock): + self.assertEqual(d[1][0][1], Result.SATISFIED) + + def test_broadcast_and_destroy(self): + for d in self.cond_results(S.cond_broadcast_wakes_all): + self.assertEqual((d[1][0][1], d[2][0][1]), (Result.SATISFIED, Result.SATISFIED)) + for d in self.cond_results(S.cond_destroy_aborts_waiter): + self.assertEqual(d[1][0][1], Result.DESTROYED) diff --git a/tools/toolchain/native_abi.c b/tools/toolchain/native_abi.c index 53b6613..7e82892 100644 --- a/tools/toolchain/native_abi.c +++ b/tools/toolchain/native_abi.c @@ -133,6 +133,11 @@ void emb_native_abi_probe(void) FIELD(event_waiters, embk_event_t, waiters); FIELD(event_bits, embk_event_t, bits); #endif +#if CONFIG_EMB_COND + TYPE(cond, embk_cond_t); + FIELD(cond_obj, embk_cond_t, obj); + FIELD(cond_waiters, embk_cond_t, waiters); +#endif #if CONFIG_EMB_MUTEX TYPE(mutex, embk_mutex_t); FIELD(mutex_obj, embk_mutex_t, obj); From db76296a05ce5c0fca404931e925fed31928e777 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 19:27:31 +0100 Subject: [PATCH 15/30] model(msgq): message queues ahead of the implementation (M2-07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KRN-IPC-022 asks for model scenarios before the M2 queue code. The reference model gains copy-by-value message queues (SPEC-007 §2): each queue has a ring of bounded capacity and two wait queues (receivers at 400 + 2i, senders at 401 + 2i). - MsgSend hands the item to the first waiting receiver, else stores it at the tail, else waits for a slot; a blocked sender keeps its item. - MsgRecv takes the head and, in the same section, moves the first waiting sender's item into the freed slot. - MsgSendFront never waits: a waiting receiver, else the head, else TIMEOUT. MsgPeek copies the head, or TIMEOUT when empty. - Send and receive are ISR-safe with EMB_NO_WAIT only. - MsgDestroy wakes both sides with DESTROYED and drops stored items. - The receive-side binding fires on the empty-to-nonempty transition, not on a hand-off. Invariants checked after every action: receivers wait only while the ring is empty, senders only while it is full, the ring never exceeds its capacity, and every accepted item is received once, still stored, or dropped by destroy. A mutation check of each (no refill from a waiting sender, a silent drop when full, storing past a waiting receiver, an uncounted drop at destroy) is caught by the new scenarios. The MSGQ scenario group is appended to the catalogue with exhaustive outcome tests. The bridge encodes the ops as qsend/qfront/qrecv/qpeek/ qdestroy with Q=[,fifo][,bind=t.b] and skips the group until a runner reports msgqs=1; existing encodings are unchanged. The evidence bundle format carries a "msgqs" key only when present. Signed-off-by: teo1747 --- CHANGELOG.md | 2 + docs/STATUS.md | 2 +- tools/model/README.md | 6 +- tools/model/bridge.py | 23 +++- tools/model/embmodel/__init__.py | 3 +- tools/model/embmodel/kernel.py | 192 +++++++++++++++++++++++++++++- tools/model/evidence.py | 6 +- tools/model/tests/scenarios.py | 75 ++++++++++++ tools/model/tests/test_explore.py | 76 ++++++++++++ 9 files changed, 374 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f98f67a..0a99f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Added +- The reference model covers copy-by-value message queues ahead of their implementation (KRN-IPC-022, M2-07): send, send_front, receive and peek, ISR calls with `EMB_NO_WAIT`, hand-off to a waiting receiver, refill from a waiting sender, destroy, and the empty-to-nonempty binding, checking in every explored state that each accepted item is received once, still stored or dropped by destroy, and that no side waits while the other could proceed. + - Condition variables (`CONFIG_EMB_COND`, `include/emb/cond.h`, M2-06): an atomic enqueue-and-release wait, signal and broadcast, and a mandatory, non-cancelable relock on every result, with `EMB_EOWNERDEAD` and relock failures taking precedence. The reference model, the differential bridge and a conformance suite cover them; they are off on the tiny profile. - Event flags (`CONFIG_EMB_EVENT`, `include/emb/event.h`, M2-05): ANY/ALL waits with optional CLEAR, deterministic consumption in queue order, ISR-safe set and clear, timeouts, cancellation, destroy with ABORT_WAITERS, and notification binding with unbind. The reference model, the differential bridge and a conformance suite cover them; they are off in the tiny `footprint_t1` reference configuration. diff --git a/docs/STATUS.md b/docs/STATUS.md index 79cc431..09eea17 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -19,7 +19,7 @@ This page is the authoritative inventory of implementation and available evidenc | Observability | M1 trace sink and fault reporting | CTF rings/metadata, deferred log decoder, retained crash records, debug descriptor and worst-case monitors remain planned | | Documentation | Sphinx/MyST manuals and native tutorials, Doxygen/Breathe C API, Sphinx Needs design catalog, JSON/CSV downloads and annotation audit | Strict local HTML build, complete extracted function annotations, source-preservation and catalog-integrity checks; merged `37887c9` Pages deployment and served evidence-workflow page independently observed successful | | Repository process | Issue forms, PR checklist, changelog, release/M2 work checklists, stable CI merge checks, authored-commit DCO checker and a main ruleset policy | Local syntax and DCO/gate failure checks; GitHub's effective main rules report PRs, resolved conversations, the three strict CI checks and deletion/non-fast-forward protection; no remote rejection experiment claimed | -| Semantic model | UP scheduler/wait/mutex/notification/lifecycle scenarios, exhaustive section-level exploration and randomized walks | Scenario-scoped exploration, not a proof of all C execution, arbitrary programs, SMP or hardware protection | +| Semantic model | UP scheduler/wait/mutex/notification/lifecycle scenarios; message queues modeled ahead of their implementation (KRN-IPC-022); exhaustive section-level exploration and randomized walks | Scenario-scoped exploration, not a proof of all C execution, arbitrary programs, SMP or hardware protection | ## Specified but unimplemented diff --git a/tools/model/README.md b/tools/model/README.md index 1ce6280..7ae4ff1 100644 --- a/tools/model/README.md +++ b/tools/model/README.md @@ -10,7 +10,7 @@ The model is written so that it can be read next to the specification: `embmodel |---|---| | `embmodel/kernel.py` | State (`Thread`, `WaitQueue`, `Semaphore`, `Mutex`, `Kernel`), the static op types (`Take`, `Give`, `Lock`, `Unlock`, `NotifySet`, `NotifyWait`, `Start`, `Join`, `Sleep`, `Yield`, `SetPrio`, `Suspend`, `Resume`, `Cancel`, `Destroy`, `SchedLock`, `SchedUnlock`, `Exit`), the wait protocol, effective priority with `effective()` and `propagate()`, interrupts and ticks, invariants | | `embmodel/explore.py` | Exhaustive depth-first exploration of every interleaving at section granularity; seeded random walks | -| `tests/scenarios.py` | The scenario catalogue: the SPEC-004 §15 worked example and the wait-protocol scenarios (`ALL`), plus the SPEC-005 §15 mutex scenarios (`MUTEX`): classic inversion, nested chain with timeouts, deadlock in both orders, owner death with and without a waiter, ceiling, waiter priority change, cancellation of a waiter, recursion, two-waiter hand-off; plus the SPEC-006 §11 notification scenarios (`NOTIFY`): set in the window, level semantics, ALL across two sets, one thread bound to two semaphores; plus the SPEC-008 §15 lifecycle scenarios (`JOIN`): join before and after exit, join with timeout, second joiner refused, canceled joiner, start of an inactive thread, exit while owning a mutex with a joiner | +| `tests/scenarios.py` | The scenario catalogue: the SPEC-004 §15 worked example and the wait-protocol scenarios (`ALL`), plus the SPEC-005 §15 mutex scenarios (`MUTEX`): classic inversion, nested chain with timeouts, deadlock in both orders, owner death with and without a waiter, ceiling, waiter priority change, cancellation of a waiter, recursion, two-waiter hand-off; plus the SPEC-006 §11 notification scenarios (`NOTIFY`): set in the window, level semantics, ALL across two sets, one thread bound to two semaphores; plus the SPEC-008 §15 lifecycle scenarios (`JOIN`): join before and after exit, join with timeout, second joiner refused, canceled joiner, start of an inactive thread, exit while owning a mutex with a joiner; plus the binding lifetime scenarios (`BINDING`, ADR-046), event flags (`EVENT`, SPEC-005 §5), condition variables (`COND`, SPEC-005 §6) and message queues (`MSGQ`, SPEC-007 §2, modeled before their implementation per KRN-IPC-022): FIFO order with a full queue, receiver hand-off, send_front and peek, ISR send and receive with `EMB_NO_WAIT`, sender timeout and cancellation, destroy of both sides, the empty-to-nonempty binding | | `tests/test_scenarios.py` | Specific interleavings with expected results | | `tests/test_explore.py` | Exhaustive exploration of every scenario plus outcome properties | | `tests/test_random.py` | Random walks over generated scenarios; a Hypothesis-generated scenario/seed campaign when installed | @@ -56,11 +56,11 @@ CTest registers this as `differential_timer_regressions` on applicable native bu ## What is checked -After every action: invariants 1 to 6 of SPEC-004 §5.4 (queue membership matches the wait state; a thread is in the ready structure exactly when runnable and not current; PRIORITY_FIFO queues are ordered; at most one dequeue per wait generation; an armed timeout belongs to a waiting thread or to the open window; the result is set before READY), semaphore unit conservation (gives equal count plus units handed to waiters plus units taken immediately), and the SPEC-005 invariants (every thread's effective priority equals `effective(base, owned)`; an inheriting mutex's owner is at least as high as every waiter on it; a ceiling mutex's owner is at least at the ceiling; an owner is never in its own queue; owned lists and owner fields agree; a free mutex has no waiters). At every terminal state: no runnable thread is left undispatched, no timeout is left armed for a blocked thread, and no blocked thread sits on an undetected inheritance cycle. +After every action: invariants 1 to 6 of SPEC-004 §5.4 (queue membership matches the wait state; a thread is in the ready structure exactly when runnable and not current; PRIORITY_FIFO queues are ordered; at most one dequeue per wait generation; an armed timeout belongs to a waiting thread or to the open window; the result is set before READY), semaphore unit conservation (gives equal count plus units handed to waiters plus units taken immediately), and the SPEC-005 invariants (every thread's effective priority equals `effective(base, owned)`; an inheriting mutex's owner is at least as high as every waiter on it; a ceiling mutex's owner is at least at the ceiling; an owner is never in its own queue; owned lists and owner fields agree; a free mutex has no waiters), and the message queue invariants (receivers wait only while the ring is empty, senders only while it is full, the ring never exceeds its capacity, and every accepted item is received once, still stored or dropped by destroy). At every terminal state: no runnable thread is left undispatched, no timeout is left armed for a blocked thread, and no blocked thread sits on an undetected inheritance cycle. ## Extending -Condition variables, event flags, barriers, and message queues are added when their conformance tests are written (SPEC-005 §17.11, SPEC-007); the SMP model adds a second CPU, the three lock domains as explicit locks, and the superseded in-flight timeout bit (SPEC-004 §8). +Barriers, pools and pipes are added when their conformance tests are written (SPEC-005 §17.11, SPEC-007); the SMP model adds a second CPU, the three lock domains as explicit locks, and the superseded in-flight timeout bit (SPEC-004 §8). ## Evidence scope and deeper-campaign triage diff --git a/tools/model/bridge.py b/tools/model/bridge.py index 38a3fa7..a264056 100644 --- a/tools/model/bridge.py +++ b/tools/model/bridge.py @@ -30,7 +30,8 @@ from embmodel import (BindNotify, Cancel, CondBroadcast, CondDestroy, CondSignal, CondWait, # noqa: E402 Destroy, EventClear, EventDestroy, EventSet, - EventWait, Exit, Give, Join, Lock, ModelError, + EventWait, Exit, Give, Join, Lock, ModelError, MsgDestroy, MsgPeek, MsgRecv, + MsgSend, MsgSendFront, NONWAIT_RESULT_OPS, NotifySet, NotifyWait, Policy, Reason, Result, Resume, Scenario, SchedLock, SchedUnlock, SetPrio, Sleep, Start, Suspend, Take, ThreadDestroy, UnbindNotify, Unlock, Yield, build, FOREVER, NO_WAIT) @@ -154,6 +155,16 @@ def encode_op(op) -> str: return f"cbcast {op.cond}" if isinstance(op, CondDestroy): return f"cdestroy {op.cond}" + if isinstance(op, MsgSend): + return f"qsend {op.q} {op.value} {_t(op.timeout)}" + if isinstance(op, MsgSendFront): + return f"qfront {op.q} {op.value}" + if isinstance(op, MsgRecv): + return f"qrecv {op.q} {_t(op.timeout)}" + if isinstance(op, MsgPeek): + return f"qpeek {op.q}" + if isinstance(op, MsgDestroy): + return f"qdestroy {op.q}" raise ValueError(f"unknown op {op!r}") @@ -180,6 +191,14 @@ def encode_scenario(sc: Scenario) -> str: tid, bit = cfg["binding"] attrs.append(f"bind={tid}.{bit}") parts.append(f"E{eid}=" + ",".join(attrs)) + for qid, cfg in sc.msgqs.items(): + attrs = [str(cfg.get("capacity", 1))] + if cfg.get("policy") == Policy.FIFO: + attrs.append("fifo") + if cfg.get("binding") is not None: + tid, bit = cfg["binding"] + attrs.append(f"bind={tid}.{bit}") + parts.append(f"Q{qid}=" + ",".join(attrs)) for mid, cfg in sc.mutexes.items(): proto = int(cfg.get("protocol", 0)) parts.append(f"M{mid}={proto},{cfg.get('ceiling', 0)},{int(bool(cfg.get('recursive', False)))}") @@ -653,6 +672,8 @@ def applicable(sc: Scenario, config: dict) -> tuple: return False, "event flags are not configured in this build" if sc.conds and config.get("conds", "0") != "1": return False, "condition variables are not configured in this build" + if sc.msgqs and config.get("msgqs", "0") != "1": + return False, "message queues are not configured in this build" if profile == "tiny": if len(set(prios)) != len(prios): return False, "shared priorities need the base profile" diff --git a/tools/model/embmodel/__init__.py b/tools/model/embmodel/__init__.py index da686f0..e68ed37 100644 --- a/tools/model/embmodel/__init__.py +++ b/tools/model/embmodel/__init__.py @@ -9,7 +9,8 @@ granularity and checks the invariants of SPEC-004 §5.4 after every step. """ from .kernel import ( # noqa: F401 - BindNotify, Cancel, CondBroadcast, CondDestroy, CondSignal, CondWait, Destroy, EventClear, EventDestroy, EventSet, EventWait, Exit, Give, Join, + BindNotify, Cancel, CondBroadcast, MsgDestroy, MsgPeek, MsgRecv, MsgSend, MsgSendFront, + NONWAIT_DATA_OPS, CondDestroy, CondSignal, CondWait, Destroy, EventClear, EventDestroy, EventSet, EventWait, Exit, Give, Join, NONWAIT_RESULT_OPS, ThreadDestroy, UnbindNotify, Kernel, Lock, ModelError, Mutex, NotifySet, NotifyWait, Policy, Protocol, Reason, Result, Resume, Start, SchedLock, SchedUnlock, Scenario, SetPrio, Sleep, Suspend, Take, Thread, Unlock, WaitState, Yield, FOREVER, NO_WAIT, OWNERDEAD_FLAG, PI_MAX_DEPTH, build, diff --git a/tools/model/embmodel/kernel.py b/tools/model/embmodel/kernel.py index 0267361..e499f0c 100644 --- a/tools/model/embmodel/kernel.py +++ b/tools/model/embmodel/kernel.py @@ -65,6 +65,7 @@ class Protocol(IntEnum): # SPEC-005 §3 MUTEX_QID_BASE = 100 # queue ids: semaphores 0..99, mutexes 100.., thread pseudo-queues negative EVENT_QID_BASE = 200 # event flags 200.. COND_QID_BASE = 300 # condition variables 300.. +MSGQ_QID_BASE = 400 # message queues: receivers 400 + 2i, senders 401 + 2i class ModelError(AssertionError): @@ -254,6 +255,40 @@ class CondDestroy: cond: int +@dataclass(frozen=True) +class MsgSend: + """emb_msgq_send(q, &value, timeout): to a waiting receiver, else the tail, else wait for a slot (SPEC-007 §2).""" + q: int + value: int + timeout: Optional[int] = FOREVER + + +@dataclass(frozen=True) +class MsgSendFront: + """emb_msgq_send_front(q, &value): never waits; to a waiting receiver, else the head, else TIMEOUT.""" + q: int + value: int + + +@dataclass(frozen=True) +class MsgRecv: + """emb_msgq_receive(q, &out, timeout): the head, refilled from a waiting sender, else wait.""" + q: int + timeout: Optional[int] = FOREVER + + +@dataclass(frozen=True) +class MsgPeek: + """emb_msgq_peek(q, &out): a copy of the head without removing it; TIMEOUT when empty.""" + q: int + + +@dataclass(frozen=True) +class MsgDestroy: + """emb_msgq_destroy(q) with EMB_OBJ_ABORT_WAITERS: waiters leave with DESTROYED, items are dropped.""" + q: int + + def event_satisfying(bits: int, mask: int, all_: bool) -> int: got = bits & mask if all_: @@ -261,7 +296,10 @@ def event_satisfying(bits: int, mask: int, all_: bool) -> int: return got -ISR_SAFE_OPS = (Give, Resume, NotifySet, Start, EventSet, EventClear) # SPEC-001 §5.2: signal-type operations are `thread isr` +NONWAIT_DATA_OPS = (MsgSendFront, MsgPeek) # record a result and data, never wait +ISR_SAFE_OPS = (Give, Resume, NotifySet, Start, EventSet, EventClear, # SPEC-001 §5.2: signal-type operations are `thread isr` + MsgSendFront, MsgPeek, MsgSend, MsgRecv) +ISR_NO_WAIT_ONLY = (MsgSend, MsgRecv) # with EMB_NO_WAIT only (SPEC-007 §1) # ---------------------------------------------------------------------------------------- @@ -300,6 +338,7 @@ class Thread: cond_phase: int = 0 # CondWait: 0 waiting, 1 relocking, 2 relocked (reporting) cond_result: Optional[Result] = None # the condition wait's result, then the final one cond_data: int = 0 # the final result's data (OWNERDEAD_FLAG) + msg_value: int = 0 # a blocked sender's item (the kernel keeps its pointer) cancel_deferred: bool = False # cancellation is not delivered (the relock, A41) def key(self): @@ -311,7 +350,7 @@ def key(self): self.notify_clear, tuple(self.owned), self.inactive, self.exit_code, self.joiner, self.collected, self.destroyed, self.cond_phase, None if self.cond_result is None else int(self.cond_result), self.cond_data, - self.cancel_deferred) + self.cancel_deferred, self.msg_value) @dataclass @@ -364,6 +403,24 @@ def key(self): return (self.cid, self.qid, self.destroyed) +@dataclass +class Msgq: + qid: int + capacity: int + recv_qid: int + send_qid: int + ring: tuple = () + destroyed: bool = False + binding: Optional[tuple] = None + accepted: int = 0 # items the queue took: handed to a receiver or stored + received: int = 0 # items delivered to receivers + dropped: int = 0 # items discarded by destroy + + def key(self): + return (self.qid, self.capacity, self.ring, self.destroyed, self.binding, self.accepted, self.received, + self.dropped) + + @dataclass class Mutex: mid: int @@ -388,6 +445,7 @@ class Scenario: mutexes: dict = field(default_factory=dict) # mid -> dict(protocol=, ceiling=, recursive=) events: dict = field(default_factory=dict) # eid -> dict(bits=, policy=, binding=) conds: dict = field(default_factory=dict) # cid -> dict(policy=) + msgqs: dict = field(default_factory=dict) # qid -> dict(capacity=, policy=, binding=) isrs: tuple = () owner_death: str = "release" # CONFIG_EMB_MUTEX_OWNER_DEATH: "release" | "fault" inactive: tuple = () # tids created INACTIVE (must be started by another thread or an ISR) @@ -409,6 +467,12 @@ def build(sc: Scenario) -> "Kernel": k.queues[sid] = WaitQueue(qid=sid, policy=cfg.get("policy", Policy.PRIORITY_FIFO)) k.sems[sid] = Semaphore(sid=sid, count=cfg.get("count", 0), initial=cfg.get("count", 0), qid=sid, binding=cfg.get("binding")) + for mq, cfg in sc.msgqs.items(): + rq, wq = MSGQ_QID_BASE + 2 * mq, MSGQ_QID_BASE + 2 * mq + 1 + k.queues[rq] = WaitQueue(qid=rq, policy=cfg.get("policy", Policy.PRIORITY_FIFO)) + k.queues[wq] = WaitQueue(qid=wq, policy=cfg.get("policy", Policy.PRIORITY_FIFO)) + k.msgqs[mq] = Msgq(qid=mq, capacity=cfg.get("capacity", 1), recv_qid=rq, send_qid=wq, + binding=cfg.get("binding")) for cid, cfg in sc.conds.items(): qid = COND_QID_BASE + cid k.queues[qid] = WaitQueue(qid=qid, policy=cfg.get("policy", Policy.PRIORITY_FIFO)) @@ -453,6 +517,7 @@ class Kernel: mutexes: dict = field(default_factory=dict) events: dict = field(default_factory=dict) conds: dict = field(default_factory=dict) + msgqs: dict = field(default_factory=dict) qid_mutex: dict = field(default_factory=dict) # static map, not part of the key ready: dict = field(default_factory=dict) current: Optional[int] = None @@ -477,6 +542,7 @@ def key(self): tuple(m.key() for _, m in sorted(self.mutexes.items())), tuple(e.key() for _, e in sorted(self.events.items())), tuple(c.key() for _, c in sorted(self.conds.items())), + tuple(q.key() for _, q in sorted(self.msgqs.items())), tuple((p, tuple(v)) for p, v in sorted(self.ready.items()) if v), self.current, self.now, self.irq_nesting, self.sched_lock_depth, self.reschedule_pending, tuple(self.timeouts), self.timer_pending, self.dequeued, @@ -492,6 +558,7 @@ def clone(self) -> "Kernel": k.mutexes = {i: copy.copy(m) for i, m in self.mutexes.items()} k.events = {i: copy.copy(e) for i, e in self.events.items()} k.conds = {i: copy.copy(c) for i, c in self.conds.items()} + k.msgqs = {i: copy.copy(q) for i, q in self.msgqs.items()} k.ready = {p: list(v) for p, v in self.ready.items()} k.timeouts = list(self.timeouts) k.trace = [] @@ -1074,7 +1141,92 @@ def _record(self, t: Thread, result: Result): def _bound_to(self, tid: int) -> bool: return any(o.binding is not None and o.binding[0] == tid - for o in list(self.sems.values()) + list(self.events.values())) + for o in list(self.sems.values()) + list(self.events.values()) + list(self.msgqs.values())) + + # ------------------------------------------------------------------ message queues (SPEC-007 §2, M2-07) + def _msgq_store(self, q: Msgq, value: int, front: bool): + was_empty = not q.ring + q.ring = (value,) + q.ring if front else q.ring + (value,) + q.accepted += 1 + if was_empty and q.binding is not None: # empty to non-empty, receive side + tid, bit = q.binding + self._notify_set(self.threads[tid], 1 << bit) + + def _msgq_put(self, q: Msgq, value: int, front: bool) -> bool: + """The non-blocking part of send: a waiting receiver, else a free slot. False when full.""" + rq = self.queues[q.recv_qid] + w = self.wait_first(rq) + if w is not None: + q.accepted += 1 + q.received += 1 + self.wake(w, Result.SATISFIED, value, rq, handoff=True) # copied into its buffer + return True + if len(q.ring) < q.capacity: + self._msgq_store(q, value, front) + return True + return False + + def _msgq_take(self, q: Msgq) -> Optional[int]: + """The non-blocking part of receive: the head, with the first waiting sender's item moved in.""" + if not q.ring: + return None + value, q.ring = q.ring[0], q.ring[1:] + q.received += 1 + sq = self.queues[q.send_qid] + w = self.wait_first(sq) + if w is not None: + q.ring = q.ring + (w.msg_value,) # the slot hand-off + q.accepted += 1 + self.wake(w, Result.SATISFIED, 0, sq, handoff=True) + return value + + def _op_msg_send(self, t: Optional[Thread], op: MsgSend) -> bool: + q = self.msgqs[op.q] + if t is None: # ISR, EMB_NO_WAIT only + if not q.destroyed: + self._msgq_put(q, op.value, front=False) + return True + if t.pc == 0 and q.destroyed: + self._finish(t, Result.STALE) + return True + if t.pc == 0: + t.msg_value = op.value + sq = self.queues[q.send_qid] + return self._block_section(t, sq, Reason.OBJECT, op.timeout, + lambda: self._msgq_room(q), lambda: self._msgq_put(q, op.value, False) and 0) + + def _msgq_room(self, q: Msgq) -> bool: + return self.wait_first(self.queues[q.recv_qid]) is not None or len(q.ring) < q.capacity + + def _op_msg_recv(self, t: Optional[Thread], op: MsgRecv) -> bool: + q = self.msgqs[op.q] + if t is None: + if not q.destroyed: + self._msgq_take(q) + return True + if t.pc == 0 and q.destroyed: + self._finish(t, Result.STALE) + return True + rq = self.queues[q.recv_qid] + return self._block_section(t, rq, Reason.OBJECT, op.timeout, lambda: bool(q.ring), lambda: self._msgq_take(q)) + + def _op_msg_nowait(self, op) -> tuple: + """send_front and peek: (result, data); neither waits.""" + q = self.msgqs[op.q] + if q.destroyed: + return Result.STALE, 0 + if isinstance(op, MsgSendFront): + return (Result.SATISFIED, 0) if self._msgq_put(q, op.value, front=True) else (Result.TIMEOUT, 0) + return (Result.SATISFIED, q.ring[0]) if q.ring else (Result.TIMEOUT, 0) + + def _op_msg_destroy(self, op: MsgDestroy): + q = self.msgqs[op.q] + if q.destroyed: + return + self.wake_all(self.queues[q.recv_qid], Result.DESTROYED) + self.wake_all(self.queues[q.send_qid], Result.DESTROYED) + q.dropped += len(q.ring) + q.ring, q.destroyed, q.binding = (), True, None # ------------------------------------------------------------------ event flags (SPEC-005 §5, M2-05) def _op_event_set(self, op: EventSet): @@ -1285,6 +1437,19 @@ def step_thread(self) -> bool: self._op_event_wait(t, op) elif isinstance(op, CondWait): self._op_cond_wait(t, op) + elif isinstance(op, MsgSend): + self._op_msg_send(t, op) + elif isinstance(op, MsgRecv): + self._op_msg_recv(t, op) + elif isinstance(op, NONWAIT_DATA_OPS): + res, data = self._op_msg_nowait(op) + t.results = t.results + ((t.op_index, res, data),) + self._advance(t) + self.reschedule_if_needed() + elif isinstance(op, MsgDestroy): + self._op_msg_destroy(op) + self._advance(t) + self.reschedule_if_needed() elif isinstance(op, (CondSignal, CondBroadcast, CondDestroy)): self._op_cond_wake(op) self._advance(t) @@ -1339,6 +1504,8 @@ def fire_isr(self, index: int): for op in self.isr_bodies[index]: if not isinstance(op, ISR_SAFE_OPS): raise ModelError(f"{op!r} is not ISR-safe (SPEC-001 §5.2)") + if isinstance(op, ISR_NO_WAIT_ONLY) and op.timeout != NO_WAIT: + raise ModelError(f"{op!r} may wait, which an ISR must not (SPEC-007 §1)") if isinstance(op, Give): self._op_give(op) elif isinstance(op, Resume): @@ -1351,6 +1518,12 @@ def fire_isr(self, index: int): self._op_event_clear(op) elif isinstance(op, Start): self._op_start(op) + elif isinstance(op, MsgSend): + self._op_msg_send(None, op) + elif isinstance(op, MsgRecv): + self._op_msg_recv(None, op) + elif isinstance(op, NONWAIT_DATA_OPS): + self._op_msg_nowait(op) self.irq_nesting -= 1 self._p1() @@ -1402,6 +1575,16 @@ def check_invariants(self): for s in self.sems.values(): # ADR-046: no binding outlives its target if s.binding is not None and self.threads[s.binding[0]].destroyed: raise ModelError(f"semaphore {s.sid} is bound to destroyed thread {s.binding[0]}") + for q in self.msgqs.values(): # SPEC-007 §2.2 + if self.queues[q.recv_qid].waiters and q.ring: + raise ModelError(f"message queue {q.qid}: receivers wait while {len(q.ring)} items are stored") + if self.queues[q.send_qid].waiters and len(q.ring) < q.capacity and not q.destroyed: + raise ModelError(f"message queue {q.qid}: senders wait while a slot is free") + if len(q.ring) > q.capacity: + raise ModelError(f"message queue {q.qid}: {len(q.ring)} items exceed capacity {q.capacity}") + if q.accepted != q.received + len(q.ring) + q.dropped: # every item once (KRN-IPC-022) + raise ModelError(f"message queue {q.qid}: {q.accepted} accepted != {q.received} received " + f"+ {len(q.ring)} stored + {q.dropped} dropped") for e in self.events.values(): # SPEC-005 §5.2: a set served every waiter it satisfied for tid, _ in self.queues[e.qid].waiters: w = self.threads[tid] @@ -1500,4 +1683,5 @@ def outcome(self) -> tuple: ("blocked", tuple(tid for tid, t in sorted(self.threads.items()) if t.wait_state == WaitState.BLOCKED)), ("mutex_owners", tuple((mid, m.owner) for mid, m in sorted(self.mutexes.items()))), ("notify_bits", tuple((tid, t.notify_bits) for tid, t in sorted(self.threads.items()))), - ("events", tuple((eid, e.bits) for eid, e in sorted(self.events.items())))) + ("events", tuple((eid, e.bits) for eid, e in sorted(self.events.items()))), + ("msgqs", tuple((qid, q.ring) for qid, q in sorted(self.msgqs.items())))) diff --git a/tools/model/evidence.py b/tools/model/evidence.py index 46da50e..4f421a3 100644 --- a/tools/model/evidence.py +++ b/tools/model/evidence.py @@ -26,7 +26,8 @@ bridge.SchedUnlock, bridge.Exit, bridge.Start, bridge.Join, bridge.BindNotify, bridge.UnbindNotify, bridge.ThreadDestroy, bridge.EventSet, bridge.EventClear, bridge.EventWait, bridge.EventDestroy, - bridge.CondWait, bridge.CondSignal, bridge.CondBroadcast, bridge.CondDestroy)} + bridge.CondWait, bridge.CondSignal, bridge.CondBroadcast, bridge.CondDestroy, + bridge.MsgSend, bridge.MsgSendFront, bridge.MsgRecv, bridge.MsgPeek, bridge.MsgDestroy)} def scenario_data(sc): @@ -41,6 +42,8 @@ def program(ops): data["events"] = list(sc.events.items()) if sc.conds: # and condition variables data["conds"] = list(sc.conds.items()) + if sc.msgqs: # and message queues + data["msgqs"] = list(sc.msgqs.items()) return data @@ -83,6 +86,7 @@ def objects(items, enum_field=None, enum_type=None): mutexes=objects(raw["mutexes"], "protocol", Protocol), events=objects(raw.get("events", []), "policy", bridge.Policy), conds=objects(raw.get("conds", []), "policy", bridge.Policy), + msgqs=objects(raw.get("msgqs", []), "policy", bridge.Policy), isrs=tuple(program(ops) for ops in raw["isrs"]), inactive=tuple(raw["inactive"]), owner_death=raw["owner_death"], tickless=raw["tickless"]) diff --git a/tools/model/tests/scenarios.py b/tools/model/tests/scenarios.py index 9dab95d..314225b 100644 --- a/tools/model/tests/scenarios.py +++ b/tools/model/tests/scenarios.py @@ -520,3 +520,78 @@ def cond_destroy_aborts_waiter(): COND = [cond_signal_not_lost, cond_timeout_relocks, cond_cancel_during_relock, cond_broadcast_wakes_all, cond_destroy_aborts_waiter] ALL = ALL + COND + + +# --------------------------------------------------------------------------------------- +# M2-07: message queues (SPEC-007 §2, KRN-IPC-022): modeled before the implementation +# --------------------------------------------------------------------------------------- +from embmodel import MsgDestroy, MsgPeek, MsgRecv, MsgSend, MsgSendFront # noqa: E402 + + +def msgq_fifo_order(): + """Capacity 2, three sends: the third waits for a slot; the receiver gets 1, 2, 3 in order.""" + return Scenario(name="msgq_fifo_order", + threads={1: (2, (MsgSend(0, 1), MsgSend(0, 2), MsgSend(0, 3))), + 2: (1, (MsgRecv(0), MsgRecv(0), MsgRecv(0)))}, + msgqs={0: dict(capacity=2)}) + + +def msgq_receiver_handoff(): + """A waiting receiver gets the item directly; the ring stays empty.""" + return Scenario(name="msgq_receiver_handoff", + threads={1: (2, (MsgRecv(0),)), 2: (1, (MsgSend(0, 7),))}, + msgqs={0: dict(capacity=1)}) + + +def msgq_send_front_and_peek(): + """send_front puts an urgent item at the head and never waits; peek copies the head without removing it.""" + return Scenario(name="msgq_send_front_and_peek", + threads={1: (1, (MsgPeek(0), MsgSend(0, 1), MsgSendFront(0, 2), MsgSendFront(0, 3), + MsgPeek(0), MsgRecv(0), MsgRecv(0), MsgRecv(0, NO_WAIT)))}, + msgqs={0: dict(capacity=2)}) + + +def msgq_isr_send_vs_timed_recv(): + """An ISR sends with EMB_NO_WAIT at any point around a timed receive: received or stored, never lost.""" + return Scenario(name="msgq_isr_send_vs_timed_recv", + threads={1: (2, (MsgRecv(0, 3),))}, + msgqs={0: dict(capacity=1)}, + isrs=((MsgSend(0, 5, NO_WAIT),),)) + + +def msgq_isr_recv_frees_a_slot(): + """An ISR receive moves the waiting sender's item into the freed slot in the same section.""" + return Scenario(name="msgq_isr_recv_frees_a_slot", + threads={1: (2, (MsgSend(0, 1), MsgSend(0, 2, 4)))}, + msgqs={0: dict(capacity=1)}, + isrs=((MsgRecv(0, NO_WAIT),),)) + + +def msgq_sender_timeout_and_cancel(): + """A full queue: one sender times out and one is canceled; neither item is ever stored.""" + return Scenario(name="msgq_sender_timeout_and_cancel", + threads={1: (3, (MsgSend(0, 1), MsgSend(0, 2, 2))), 2: (2, (MsgSend(0, 3),)), + 3: (1, (Cancel(2), Sleep(3), MsgRecv(0), MsgRecv(0, NO_WAIT)))}, + msgqs={0: dict(capacity=1)}) + + +def msgq_destroy_aborts_both_sides(): + """Destroy wakes a waiting receiver and a waiting sender with DESTROYED and drops the stored item.""" + return Scenario(name="msgq_destroy_aborts_both_sides", + threads={1: (4, (MsgRecv(0),)), 2: (3, (MsgSend(1, 1), MsgSend(1, 2))), + 3: (1, (MsgDestroy(0), MsgDestroy(1)))}, + msgqs={0: dict(capacity=1), 1: dict(capacity=1)}) + + +def msgq_binding_on_empty_to_nonempty(): + """The bound thread is notified when the ring goes from empty to nonempty, not on a hand-off.""" + return Scenario(name="msgq_binding_on_empty_to_nonempty", + threads={1: (3, (NotifyWait(0x1, timeout=4),)), 2: (2, (MsgRecv(0),)), + 3: (1, (MsgSend(0, 1), MsgSend(0, 2), MsgSend(0, 3)))}, + msgqs={0: dict(capacity=2, binding=(1, 0))}) + + +MSGQ = [msgq_fifo_order, msgq_receiver_handoff, msgq_send_front_and_peek, msgq_isr_send_vs_timed_recv, + msgq_isr_recv_frees_a_slot, msgq_sender_timeout_and_cancel, msgq_destroy_aborts_both_sides, + msgq_binding_on_empty_to_nonempty] +ALL = ALL + MSGQ diff --git a/tools/model/tests/test_explore.py b/tools/model/tests/test_explore.py index 251e81a..b8f7d50 100644 --- a/tools/model/tests/test_explore.py +++ b/tools/model/tests/test_explore.py @@ -361,3 +361,79 @@ def test_broadcast_and_destroy(self): self.assertEqual((d[1][0][1], d[2][0][1]), (Result.SATISFIED, Result.SATISFIED)) for d in self.cond_results(S.cond_destroy_aborts_waiter): self.assertEqual(d[1][0][1], Result.DESTROYED) + + +class MessageQueues(unittest.TestCase): + """M2-07 (SPEC-007 §2, KRN-IPC-022): FIFO copy semantics, hand-off both ways, send_front, peek, + ISR no-wait calls, timeouts and cancellation, destroy, and the empty-to-nonempty binding. The + every-item-once and waiting-side invariants are checked in every explored state.""" + + @staticmethod + def outcomes(make): + return [(dict(dict(o)["threads"]), dict(dict(o)["msgqs"])) for o in explore(make()).outcomes] + + def test_every_msgq_scenario_holds_invariants(self): + for make in S.MSGQ: + sc = make() + with self.subTest(scenario=sc.name): + self.assertGreater(explore(sc).terminals, 0) + + def test_items_arrive_in_send_order(self): + for d, q in self.outcomes(S.msgq_fifo_order): + self.assertEqual([r[2] for r in d[2]], [1, 2, 3]) + self.assertEqual([r[1] for r in d[1]], [Result.SATISFIED] * 3) + self.assertEqual(q[0], ()) + + def test_waiting_receiver_gets_the_item_directly(self): + for d, q in self.outcomes(S.msgq_receiver_handoff): + self.assertEqual((d[1][0][1], d[1][0][2]), (Result.SATISFIED, 7)) + self.assertEqual(q[0], ()) + + def test_send_front_and_peek(self): + for d, q in self.outcomes(S.msgq_send_front_and_peek): + self.assertEqual([(r[1], r[2]) for r in d[1]], + [(Result.TIMEOUT, 0), (Result.SATISFIED, 0), (Result.SATISFIED, 0), + (Result.TIMEOUT, 0), (Result.SATISFIED, 2), (Result.SATISFIED, 2), + (Result.SATISFIED, 1), (Result.TIMEOUT, 0)]) + self.assertEqual(q[0], ()) + + def test_isr_send_is_received_or_kept(self): + seen = set() + for d, q in self.outcomes(S.msgq_isr_send_vs_timed_recv): + r = d[1][0][1] + seen.add(r) + self.assertEqual(q[0], () if r == Result.SATISFIED else (5,)) + self.assertEqual(seen, {Result.SATISFIED, Result.TIMEOUT}) + + def test_isr_receive_refills_from_the_waiting_sender(self): + seen = set() + for d, q in self.outcomes(S.msgq_isr_recv_frees_a_slot): + r = d[1][1][1] + seen.add(r) + # SATISFIED: the ISR freed the slot before or during the wait, and item 2 took it; + # TIMEOUT: the ISR ran first (nothing to take) or after the timeout (took item 1). + self.assertIn(q[0], ((2,),) if r == Result.SATISFIED else ((1,), ())) + self.assertEqual(seen, {Result.SATISFIED, Result.TIMEOUT}) + + def test_timed_out_and_canceled_items_are_never_stored(self): + for d, q in self.outcomes(S.msgq_sender_timeout_and_cancel): + self.assertEqual(d[1][1][1], Result.TIMEOUT) + self.assertEqual(d[2][0][1], Result.CANCELED) + self.assertEqual([(r[1], r[2]) for r in d[3] if r[0] >= 2], [(Result.SATISFIED, 1), (Result.TIMEOUT, 0)]) + self.assertEqual(q[0], ()) + + def test_destroy_aborts_receivers_and_senders(self): + for d, q in self.outcomes(S.msgq_destroy_aborts_both_sides): + self.assertEqual(d[1][0][1], Result.DESTROYED) + self.assertEqual([r[1] for r in d[2]], [Result.SATISFIED, Result.DESTROYED]) + self.assertEqual(q, {0: (), 1: ()}) + + def test_binding_fires_on_the_ready_transition_only(self): + seen = set() + for d, q in self.outcomes(S.msgq_binding_on_empty_to_nonempty): + self.assertEqual((d[2][0][1], d[2][0][2]), (Result.SATISFIED, 1)) # hand-off: no signal + seen.add(d[1][0][1]) + if d[1][0][1] == Result.SATISFIED: + self.assertEqual(d[1][0][2], 1) # storing 2 signaled bit 0 + self.assertEqual(q[0], (2, 3)) + self.assertEqual(seen, {Result.SATISFIED, Result.TIMEOUT}) From 0e8d6ff2f03b322033855f8b4e604075a31c969d Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 19:45:03 +0100 Subject: [PATCH 16/30] feat(ipc): copy-by-value message queues (M2-07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Message queues (SPEC-007 §2, KRN-IPC-010) under CONFIG_EMB_MSGQ, in include/emb/msgq.h and kernel/ipc/msgq.c, built on the model merged first (KRN-IPC-022). - Fixed-size items are copied by value through a caller-provided ring declared with EMB_MSGQ_RING(). Init refuses a missing, misaligned or undersized ring, an item size of 0 or above CONFIG_EMB_MSGQ_MAX_ITEM (64, tiny 16) and a capacity of 0 (A43). - A send to a waiting receiver copies into its out_item, whose pointer waits in wake_data, and leaves the ring untouched. A receive that frees a slot moves the first waiting sender's item, whose pointer waits in wake_data too, into the ring. A woken thread never re-checks, and each section copies at most two items. - send_front takes no timeout and is not a cancellation point; peek on an empty queue returns EMB_ETIMEDOUT; the unreachable receive branch for a sender waiting on an empty ring is gone (A44). - send, receive, send_front and peek work from ISRs with EMB_NO_WAIT; the _until forms are thread-only (A47). - The receive-side binding fires on empty to nonempty, not on a hand-off and not on a refill from a waiting sender (A46). - Destroy drops stored items and, with ABORT_WAITERS, wakes receivers and senders with EMB_EDESTROYED. - CONFIG_EMB_IPC_SMALL (y on tiny) keeps 8-bit counts: a capacity above 255 is refused (A45). Message queues are off in footprint_t1, whose measurement is unchanged (4,058 text, 39 RAM, 32-byte TCB on AVR). API IDs 102-114, trace IDs 35/36 (send, receive: caller, queue and status of every outcome), type tag 0x56. The runner gains Q=[,fifo][,bind=t.b], the qsend, qfront, qrecv, qpeek and qdestroy operations and the final ring contents; the bridge maps thread-context send and receive records to the end of the wait, drops ISR-context ones, and compares received data and rings. The model now reschedules after a send or receive that wakes a higher thread and traces send_front like a send. A new scenario, msgq_refill_does_not_signal_binding, catches a kernel that signals the binding on a refill, which the earlier group missed. tests/conformance/test_msgq.c has 11 cases; the resource-bound inventory gains item_copy and the msgq functions. Signed-off-by: teo1747 --- CHANGELOG.md | 2 + configs/footprint-t1.conf | 1 + docs/STATUS.md | 5 +- docs/engineering/m2-checklist.md | 2 +- docs/engineering/resource-bounds.json | 51 ++- docs/engineering/resource-bounds.md | 11 +- docs/requirements/KRN-IPC.md | 32 +- docs/specs/AMENDMENTS-M2.md | 5 + .../SPEC-007-inter-thread-communication.md | 25 +- docs/user/feature-status.md | 4 +- docs/user/synchronization.md | 32 ++ include/emb/api_ids.h | 13 + include/emb/emb.h | 3 + include/emb/msgq.h | 309 ++++++++++++++ include/emb/trace.h | 14 +- kernel/CMakeLists.txt | 3 + kernel/Kconfig | 24 ++ kernel/include/embk/ipc.h | 41 ++ kernel/include/embk/kernel.h | 1 + kernel/ipc/msgq.c | 402 ++++++++++++++++++ kernel/object/storage_probe.c | 4 + tests/conformance/CMakeLists.txt | 3 + tests/conformance/test_msgq.c | 296 +++++++++++++ tests/differential/diff_runner.c | 174 +++++++- tools/fault/api-ids.json | 78 ++++ tools/fault/api-registry.json | 78 ++++ tools/model/bridge.py | 22 +- tools/model/embmodel/kernel.py | 17 +- tools/model/tests/scenarios.py | 11 +- tools/model/tests/test_explore.py | 6 + tools/toolchain/native_abi.c | 8 + 31 files changed, 1621 insertions(+), 56 deletions(-) create mode 100644 include/emb/msgq.h create mode 100644 kernel/include/embk/ipc.h create mode 100644 kernel/ipc/msgq.c create mode 100644 tests/conformance/test_msgq.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a99f33..101bd87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Added +- Message queues (`CONFIG_EMB_MSGQ`, `include/emb/msgq.h`, M2-07): fixed-size items copied by value through a caller-provided ring declared with `EMB_MSGQ_RING()`, with direct hand-off to a waiting receiver, slot hand-off from a waiting sender, `send_front`, `peek`, `count` and `free`, `EMB_NO_WAIT` calls from interrupts, timeouts and absolute deadlines, cancellation, destroy with ABORT_WAITERS, and a receive-side notification binding. Items are bounded by `CONFIG_EMB_MSGQ_MAX_ITEM` (64 bytes, 16 on tiny, where `CONFIG_EMB_IPC_SMALL` keeps 8-bit counts). The reference model, the differential bridge and a conformance suite cover them; they are off in the tiny `footprint_t1` reference configuration. + - The reference model covers copy-by-value message queues ahead of their implementation (KRN-IPC-022, M2-07): send, send_front, receive and peek, ISR calls with `EMB_NO_WAIT`, hand-off to a waiting receiver, refill from a waiting sender, destroy, and the empty-to-nonempty binding, checking in every explored state that each accepted item is received once, still stored or dropped by destroy, and that no side waits while the other could proceed. - Condition variables (`CONFIG_EMB_COND`, `include/emb/cond.h`, M2-06): an atomic enqueue-and-release wait, signal and broadcast, and a mandatory, non-cancelable relock on every result, with `EMB_EOWNERDEAD` and relock failures taking precedence. The reference model, the differential bridge and a conformance suite cover them; they are off on the tiny profile. diff --git a/configs/footprint-t1.conf b/configs/footprint-t1.conf index 64684e7..fa7f6ae 100644 --- a/configs/footprint-t1.conf +++ b/configs/footprint-t1.conf @@ -7,3 +7,4 @@ # CONFIG_EMB_TRACE is not set # CONFIG_EMB_FAULT_HOOK_RUNTIME is not set # CONFIG_EMB_EVENT is not set +# CONFIG_EMB_MSGQ is not set diff --git a/docs/STATUS.md b/docs/STATUS.md index 09eea17..a9b42a9 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -11,6 +11,7 @@ This page is the authoritative inventory of implementation and available evidenc | Execution and scheduling | UP fixed priority, base bitmap/FIFO scheduler, tiny thread table, scheduler lock, thread lifecycle; idle context unreachable through handles, and slim (control-block prefix only) on tiny without an idle thread (ADR-050) | Common conformance cases; tiny has unique base priorities and restricted scheduling semantics | | Waiting and time | Shared wait protocol, sleep, deadlines, cancellation, suspend overlay, ordered timeouts | Native virtual time exercises periodic and tickless modes; embedded ports currently reject tickless | | Synchronization | Semaphores, mutexes with inheritance, ceiling, recursion and owner-death policy; event flags with ANY/ALL/CLEAR waits and binding (M2-05); condition variables with the mandatory relock (M2-06) | Conformance and native differential bridge; inheritance and deadlock detection have a configured depth limit | +| Communication | Copy-by-value message queues: direct hand-off to a waiting receiver, slot hand-off from a waiting sender, send to front, peek, `EMB_NO_WAIT` calls from ISRs and a receive-side binding (M2-07) | Conformance and native differential bridge; no pipes, buffer pools or zero-copy ownership queues; the masked copy time is bounded by configuration, not measured on hardware | | Notifications | Per-thread bits and one semaphore-to-thread binding, with unbind; a bound thread cannot be destroyed (ADR-046) | Binding takes a one-bit mask; other objects' bindings arrive with those objects; restart generations need the isolated profile | | Objects | Caller-owned generated storage and POINTER handles; per-type active-object lists for semaphores and mutexes (`CONFIG_EMB_OBJ_LIST`) used by init | No INDEXED or TABLE handles, capability rights, partition restart, system freeze, `emb_obj_foreach()` or debug-descriptor export | | Native port | POSIX host-thread gate, deterministic IRQ injection, virtual time | Base/tiny development workflow; no current wall-clock, asynchronous signal stress, simulated isolated profile or Win32 backend | @@ -19,11 +20,11 @@ This page is the authoritative inventory of implementation and available evidenc | Observability | M1 trace sink and fault reporting | CTF rings/metadata, deferred log decoder, retained crash records, debug descriptor and worst-case monitors remain planned | | Documentation | Sphinx/MyST manuals and native tutorials, Doxygen/Breathe C API, Sphinx Needs design catalog, JSON/CSV downloads and annotation audit | Strict local HTML build, complete extracted function annotations, source-preservation and catalog-integrity checks; merged `37887c9` Pages deployment and served evidence-workflow page independently observed successful | | Repository process | Issue forms, PR checklist, changelog, release/M2 work checklists, stable CI merge checks, authored-commit DCO checker and a main ruleset policy | Local syntax and DCO/gate failure checks; GitHub's effective main rules report PRs, resolved conversations, the three strict CI checks and deletion/non-fast-forward protection; no remote rejection experiment claimed | -| Semantic model | UP scheduler/wait/mutex/notification/lifecycle scenarios; message queues modeled ahead of their implementation (KRN-IPC-022); exhaustive section-level exploration and randomized walks | Scenario-scoped exploration, not a proof of all C execution, arbitrary programs, SMP or hardware protection | +| Semantic model | UP scheduler/wait/mutex/notification/lifecycle scenarios; message queues, modeled before their implementation (KRN-IPC-022); exhaustive section-level exploration and randomized walks | Scenario-scoped exploration, not a proof of all C execution, arbitrary programs, SMP or hardware protection | ## Specified but unimplemented -Barriers, software timers, work queues, message queues, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. +Barriers, software timers, work queues, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. An API or path named in a design or a requirement's verification field may be a planned deliverable. Check this inventory and the filesystem before treating it as available. diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index 64c2d13..0d9239d 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -77,7 +77,7 @@ The `footprint_t1` reference build was at the R-003 T2 limit of 64 bytes of kern - [x] **Event flags:** model overlapping masks and ANY/ALL/CLEAR; test deterministic waiter order, clearing before the next waiter, multi-wake cost, ISR set, timeout/cancel/destroy and binding semantics. Follow SPEC-005 and KRN-SYNC-029. - [x] **Condition variables:** model atomic mutex release/enqueue, no stored signal, and mandatory mutex reacquisition even after timeout/cancel; test broadcasts, priority inheritance, recursive-mutex restrictions and relock response-time implications. Follow SPEC-005 and KRN-SYNC-030. -- [ ] **Message queues:** start with the copy-by-value subset; validate item-size/capacity arithmetic and aligned caller storage, direct receiver/slot hand-off, front send, no-wait ISR paths, absolute deadlines, cancellation/destruction and readiness recheck. Bound bytes copied while masked. Follow SPEC-007/KRN-IPC; ownership queues depend on a later pool gate and must not be advertised early. +- [x] **Message queues:** start with the copy-by-value subset; validate item-size/capacity arithmetic and aligned caller storage, direct receiver/slot hand-off, front send, no-wait ISR paths, absolute deadlines, cancellation/destruction and readiness recheck. Bound bytes copied while masked. Follow SPEC-007/KRN-IPC; ownership queues depend on a later pool gate and must not be advertised early. - [ ] **Work queues:** define callback thread/priority and serialized execution; model submit/resubmit states and cancel/flush including callback self-wait misuse, delayed scheduling and teardown. Follow SPEC-006/KRN-WQ; expose only implemented configurations. - [ ] **Software timers:** use the work queue for default callbacks; model absolute periodic rearm, missed-period policy and checked overflow arithmetic, cancel/destroy while callbacks are queued/running, and any explicitly supported ISR callback mode. Follow SPEC-003/KRN-TIM; do not silently enable embedded tickless before its port contract is implemented. diff --git a/docs/engineering/resource-bounds.json b/docs/engineering/resource-bounds.json index 72ab73c..ee13cc7 100644 --- a/docs/engineering/resource-bounds.json +++ b/docs/engineering/resource-bounds.json @@ -95,7 +95,7 @@ }, { "id": "active_objects", - "input": "active semaphores or mutexes of one type", + "input": "active objects of one type (semaphores, mutexes, event flags, condition variables, message queues)", "bound": "not limited by the kernel: the objects the application initializes", "limit": "application", "enforcement": "application", @@ -103,6 +103,16 @@ "profiles": "with CONFIG_EMB_OBJ_LIST only (not on tiny); walked by init", "evidence": "ADR-045; m2-02-object-lifecycle.md" }, + { + "id": "item_copy", + "input": "bytes copied for one message queue operation", + "bound": "at most two items (a receive that refills from a waiting sender), each item_size bytes", + "limit": "CONFIG_EMB_MSGQ_MAX_ITEM (default 64, tiny 16); init refuses a larger item_size", + "enforcement": "configuration", + "masked": true, + "profiles": "a byte loop under the object lock on every profile", + "evidence": "SPEC-007 \u00a72.2, KRN-IPC-011; tests/conformance/test_msgq.c" + }, { "id": "stack", "input": "bytes of one thread's stack", @@ -298,6 +308,45 @@ "batch_wake", "timeouts", "reschedule" + ], + "emb_msgq_init": [ + "active_objects" + ], + "emb_msgq_destroy": [ + "batch_wake", + "reschedule" + ], + "emb_msgq_send": [ + "item_copy", + "waiters", + "timeouts", + "reschedule" + ], + "emb_msgq_send_until": [ + "item_copy", + "waiters", + "timeouts", + "reschedule" + ], + "emb_msgq_receive": [ + "item_copy", + "waiters", + "timeouts", + "reschedule" + ], + "emb_msgq_receive_until": [ + "item_copy", + "waiters", + "timeouts", + "reschedule" + ], + "emb_msgq_send_front": [ + "item_copy", + "timeouts", + "reschedule" + ], + "emb_msgq_peek": [ + "item_copy" ] } } diff --git a/docs/engineering/resource-bounds.md b/docs/engineering/resource-bounds.md index da03349..9e26a3d 100644 --- a/docs/engineering/resource-bounds.md +++ b/docs/engineering/resource-bounds.md @@ -15,7 +15,8 @@ Every cost input named by a public function's `@time` annotation, with how it is | **batch_wake**: waiters woken at once (destroy with EMB_OBJ_ABORT_WAITERS) | the waiters of the destroyed object | as for waiters | structural | yes | one section, one reschedule at the end | SPEC-004 §6.7; KRN-OBJ-001 | | **thread_ids**: diagnostic identity scan at thread init | 254 IDs on list profiles, CONFIG_EMB_PRIORITY_COUNT - 1 on tiny | fixed per profile; EMB_ENOMEM beyond it | admission | yes | bitmap scan from the lowest ID | ADR-043; m2-02-thread-identity.md | | **live_threads**: threads on the all-threads list | at most 254 (the ID space) | CONFIG_EMB_THREAD_LIST; the ID admission | admission | yes | base only (no list on tiny); walked by thread init and emb_thread_foreach | SPEC-008 §12; ADR-045 | -| **active_objects**: active semaphores or mutexes of one type | not limited by the kernel: the objects the application initializes | application | application | yes | with CONFIG_EMB_OBJ_LIST only (not on tiny); walked by init | ADR-045; m2-02-object-lifecycle.md | +| **active_objects**: active objects of one type (semaphores, mutexes, event flags, condition variables, message queues) | not limited by the kernel: the objects the application initializes | application | application | yes | with CONFIG_EMB_OBJ_LIST only (not on tiny); walked by init | ADR-045; m2-02-object-lifecycle.md | +| **item_copy**: bytes copied for one message queue operation | at most two items (a receive that refills from a waiting sender), each item_size bytes | CONFIG_EMB_MSGQ_MAX_ITEM (default 64, tiny 16); init refuses a larger item_size | configuration | yes | a byte loop under the object lock on every profile | SPEC-007 §2.2, KRN-IPC-011; tests/conformance/test_msgq.c | | **stack**: bytes of one thread's stack | the stack size the application supplied | application | application | no | the high-water scan exists only with CONFIG_EMB_STACK_STATS; init fills the stack in checked and statistics builds | SPEC-008 §10; KRN-MEM-009 | | **init_table**: entries of the initialization table | the EMB_*_DEFINE objects linked into the image | application, fixed at link time | application | no | prekernel only | KRN-OBJ-009 | | **callbacks**: application callbacks the kernel calls (fault hook, trace sink, foreach visitor) | not bounded by the kernel | application | application | yes | the fault hook and the trace sink can run with interrupts masked; the foreach visitor runs under the scheduler lock | API-017; SPEC-015 | @@ -39,6 +40,14 @@ Every cost input named by a public function's `@time` annotation, with how it is | `emb_fault()` | O(1) preparation plus trace/hook and the non-returning port action | callbacks, port_action | | `emb_fault_hook()` | the application's | callbacks | | `emb_kernel_init()` | O(init table) | init_table | +| `emb_msgq_destroy()` | O(waiters) with EMB_OBJ_ABORT_WAITERS | batch_wake, reschedule | +| `emb_msgq_init()` | O(1); O(active message queues) list lookup with CONFIG_EMB_OBJ_LIST | active_objects | +| `emb_msgq_peek()` | O(1) plus an item copy | item_copy | +| `emb_msgq_receive()` | O(1) plus up to two item copies; O(waiters + timeouts) finite wait enqueue | item_copy, waiters, timeouts, reschedule | +| `emb_msgq_receive_until()` | as emb_msgq_receive() | item_copy, waiters, timeouts, reschedule | +| `emb_msgq_send()` | O(1) plus an item copy; O(waiters + timeouts) finite wait enqueue | item_copy, waiters, timeouts, reschedule | +| `emb_msgq_send_front()` | O(1) plus an item copy | item_copy, timeouts, reschedule | +| `emb_msgq_send_until()` | as emb_msgq_send() | item_copy, waiters, timeouts, reschedule | | `emb_mutex_destroy()` | O(waiters + owned + PI work), plus profile-specific timeout removals | batch_wake, owned, pi_depth, timeouts, reschedule | | `emb_mutex_init()` | O(1); O(active mutexes) list lookup with CONFIG_EMB_OBJ_LIST | active_objects | | `emb_mutex_lock()` | O(owned) acquisition; O(waiters + PI work + timeouts) finite contended wait | owned, waiters, pi_depth, timeouts | diff --git a/docs/requirements/KRN-IPC.md b/docs/requirements/KRN-IPC.md index 841f1b8..a762ae3 100644 --- a/docs/requirements/KRN-IPC.md +++ b/docs/requirements/KRN-IPC.md @@ -25,8 +25,8 @@ KRN-IPC-001 to 005 originate in the v0.1 specification (§9.4); KRN-IPC-006 to 0 ### KRN-IPC-003 ISR-safe IPC never blocks **Statement.** ISR-safe IPC operations shall never block. **Rationale.** SPEC-001 §5.2. -**Status.** Accepted 2026-10-07 -**Verification.** Analysis: `@ctx thread isr` forms accept only `EMB_NO_WAIT`; Test: `misuse/ipc_block_from_isr`. +**Status.** Accepted 2026-10-07; implemented for message queues (M2-07). +**Verification.** Analysis: `@ctx thread isr` forms accept only `EMB_NO_WAIT`; Test: `msgq_isr_send_and_receive_never_wait`; the model refuses an ISR send or receive that may wait. Pipes and pools are not implemented. **Trace.** SPEC-007 §1, §7 ### KRN-IPC-004 Explicit zero-copy ownership @@ -74,22 +74,22 @@ KRN-IPC-001 to 005 originate in the v0.1 specification (§9.4); KRN-IPC-006 to 0 ### KRN-IPC-010 Message queue semantics **Statement.** A message queue shall copy fixed-size items by value into a caller-provided ring; send with a waiting receiver shall copy directly into the receiver and wake it; receive that frees a slot shall move a waiting sender's item into it and wake the sender; a woken thread shall not re-check; a full queue shall return `EMB_ETIMEDOUT` to a non-blocking sender and never drop. **Rationale.** Hand-off semantics (KRN-WAIT-012) applied to both sides; no silent drops (R-001 §6, RIOT). -**Status.** Accepted 2026-10-07 -**Verification.** Test: `ipc/msgq_*` including `msgq_direct_to_receiver`, `msgq_slot_handoff_to_sender`, `msgq_full_nowait_timedout`. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-07). +**Verification.** Test: `msgq_fifo_order_counts_and_peek`, `msgq_waiting_receivers_get_items_directly_in_priority_order`, `msgq_receive_refills_the_freed_slot_from_a_waiting_sender`, `msgq_waits_time_out_and_cancel_without_storing`, `msgq_destroy_aborts_receivers_and_senders`; model scenarios of the MSGQ group with the conservation and waiting-side invariants; differential agreement on the native presets. **Trace.** SPEC-007 §2.2 ### KRN-IPC-011 Bounded item copy under the lock **Statement.** `item_size` shall be bounded by `CONFIG_EMB_MSGQ_MAX_ITEM` (default 64, tiny 16); the copy shall run inside the object's critical section and its cost shall be included in the masked-time budget. **Rationale.** Simplicity and bounded masking (R-003 T7) without a reservation protocol. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `misuse/msgq_item_too_large`; Benchmark: masked time of a maximum-size send. +**Status.** Accepted 2026-10-07; implemented for message queues (M2-07); the masked-time benchmark is not run. +**Verification.** Test: `msgq_init_validates_the_ring`; the `item_copy` entry of the resource-bound inventory; Benchmark: masked time of a maximum-size send (not run: no board). **Trace.** SPEC-007 §2.2, §2.3 ### KRN-IPC-012 Send to front **Statement.** `send_front` shall place an item at the head, shall accept only `EMB_NO_WAIT`, and shall be ISR-safe. **Rationale.** Urgent messages without a second queue (03 §6.3). -**Status.** Accepted 2026-10-07 -**Verification.** Test: `ipc/msgq_send_front_order`. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-07). +**Verification.** Test: `msgq_send_front_is_received_first_and_never_waits`; model scenario `msgq_send_front_and_peek`. **Trace.** SPEC-007 §2.2 ### KRN-IPC-013 Pipe semantics @@ -130,15 +130,15 @@ KRN-IPC-001 to 005 originate in the v0.1 specification (§9.4); KRN-IPC-006 to 0 ### KRN-IPC-018 Bindings on IPC objects **Statement.** Message queues (receive side, empty to non-empty), pipes (fill reaching `notify_trigger`), and pools (exhausted to available) shall support the notification binding, and a transition consumed by a hand-off shall not signal it. **Rationale.** KRN-NOTIF-004. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `notif/bind_msgq`, `notif/bind_pipe`, `notif/bind_pool`. +**Status.** Accepted 2026-10-07; implemented for message queues (M2-07); pipes and pools are not implemented. +**Verification.** Test: `msgq_binding_fires_on_empty_to_nonempty_only`; model scenarios `msgq_binding_on_empty_to_nonempty`, `msgq_refill_does_not_signal_binding`. **Trace.** SPEC-007 §2.2, §3.2, §4.2; SPEC-006 §3 ### KRN-IPC-019 Storage declaration helpers **Statement.** `EMB_MSGQ_RING`, `EMB_POOL_STORAGE`, and the pipe buffer rules shall declare correctly sized and aligned caller storage; misaligned or undersized storage shall be misuse. **Rationale.** Word copies and intrusive free lists need alignment the application should not have to compute. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `misuse/ipc_storage_misaligned`; Analysis: macro review. +**Status.** Accepted 2026-10-07; implemented for message queues (`EMB_MSGQ_RING`, M2-07). +**Verification.** Test: `msgq_init_validates_the_ring` (missing, misaligned and undersized rings); Analysis: macro review. **Trace.** SPEC-007 §2.1, §4.1, §7 ### KRN-IPC-020 Trace and statistics @@ -151,13 +151,13 @@ KRN-IPC-001 to 005 originate in the v0.1 specification (§9.4); KRN-IPC-006 to 0 ### KRN-IPC-021 Tiny profile **Statement.** The tiny profile shall offer message queues and pools with 8-bit counts under `CONFIG_EMB_IPC_SMALL`, a 16-byte default item bound, and pipes only when configured. **Rationale.** Footprint targets T1 and T2. -**Status.** Accepted 2026-10-07 -**Verification.** Footprint test of the tiny reference configuration. +**Status.** Accepted 2026-10-07; message queues implemented with `CONFIG_EMB_IPC_SMALL` and a 16-byte item default on tiny (A45); pools and pipes are not implemented. +**Verification.** Footprint test of the tiny reference configuration, which excludes message queues; the native, QEMU, AVR and EmbSIM tiny presets run `test_msgq`. **Trace.** SPEC-007 §9 ### KRN-IPC-022 Model coverage **Statement.** Message queues and pools shall be added to the reference model with their M2 conformance tests, with conservation properties (every item received exactly once or still in the ring; every block owned by one party or free). **Rationale.** The same evidence standard as semaphores and mutexes. -**Status.** Accepted 2026-10-07 -**Verification.** Analysis: `tools/model` scenarios exist before the M2 implementation of queues and pools. +**Status.** Accepted 2026-10-07; met for message queues, whose model scenarios preceded the implementation (M2-07); pools are not modeled. +**Verification.** Analysis: the MSGQ scenario group and its invariants (`tools/model`) were merged before `kernel/ipc/msgq.c`. **Trace.** SPEC-007 §10; KRN-WAIT-007 diff --git a/docs/specs/AMENDMENTS-M2.md b/docs/specs/AMENDMENTS-M2.md index 742929a..9121c14 100644 --- a/docs/specs/AMENDMENTS-M2.md +++ b/docs/specs/AMENDMENTS-M2.md @@ -18,3 +18,8 @@ Corrections found while preparing the M2 primitives, where an accepted specifica | A40 | SPEC-005 §5.1, SPEC-001 §6.4 | Event flags get `emb_event_attr_default()` and `emb_event_unbind_notify()`; like semaphores and mutexes they have no `_DEFINE` macro yet, which stays one cross-type change. | `include/emb/event.h` | | A41 | SPEC-005 §6 | The mandatory relock of a condition wait defers cancellation (the thread's cancel-disable count is raised around it), so a cancel cannot return the caller without its mutex; it is delivered at the next cancellation point. | `kernel/sync/cond.c` | | A42 | SPEC-005 §6 | Condition wait results: `EMB_EOWNERDEAD` or a relock failure (`EMB_EDEADLK`, `EMB_EDESTROYED`) wins over the wait's result; a pending cancel or `EMB_NO_WAIT` returns before the mutex is released. `emb_cond_attr_default()` and `emb_cond_attr_t` (name, flags) are defined. | `include/emb/cond.h` | +| A43 | SPEC-007 §2.1, §7 | The ring is named `ring` with its size in `ring_size`, so init can refuse an undersized ring as §7 requires and the field is not confused with the object's own storage argument; `EMB_MSGQ_RING_SIZE()` gives the size. `pool` and `EMB_MSGQ_OWNERSHIP` are omitted until buffer pools exist, and `emb_msgq_attr_default()` and `emb_msgq_unbind_notify()` are added like the other M2 objects. | `include/emb/msgq.h` | +| A44 | SPEC-007 §2.2 | `send_front` takes no timeout: it hands off to a waiting receiver, else writes at the head, else returns `EMB_ETIMEDOUT`, and is not a cancellation point. The receive branch for a sender waiting on an empty ring is unreachable (senders wait only while full, capacity at least 1) and is removed. `peek` on an empty queue returns `EMB_ETIMEDOUT`. | `kernel/ipc/msgq.c` | +| A45 | SPEC-007 §2.1, §9, KRN-IPC-021 | Public counts and sizes are `uint16_t`. With `CONFIG_EMB_IPC_SMALL` the object keeps 8-bit counts, so a capacity above 255 is an argument misuse and `CONFIG_EMB_MSGQ_MAX_ITEM` is limited to 255. | `kernel/include/embk/ipc.h` | +| A46 | SPEC-007 §2.2, KRN-IPC-018 | A receive that refills the freed slot from a waiting sender does not signal the binding: the queue is never observed empty, which is the hand-off rule applied to the send side. | `kernel/ipc/msgq.c` | +| A47 | SPEC-007 §1, §8, SPEC-015 | `send`, `receive`, `send_front` and `peek` are `thread isr` (waits from an ISR are `EMB_FAULT_API_CONTEXT`/`EMB_EPERM`); the `_until` forms are thread-only. The trace records are `EMB_TRACE_MSGQ_SEND` (send and send_front) and `EMB_TRACE_MSGQ_RECEIVE`, with the caller's index, the queue and the status of every outcome (A19); a waiting receiver or sender shows as the partner's wake record and the generic block record. `peek`, `count` and `free` are not traced. | `kernel/ipc/msgq.c`, `include/emb/trace.h` | diff --git a/docs/specs/SPEC-007-inter-thread-communication.md b/docs/specs/SPEC-007-inter-thread-communication.md index 694b7ff..e1b5a7a 100644 --- a/docs/specs/SPEC-007-inter-thread-communication.md +++ b/docs/specs/SPEC-007-inter-thread-communication.md @@ -27,15 +27,19 @@ A **message** is a fixed-size item copied by value. A **block** is a fixed-size ```c typedef struct emb_msgq_attr { const char *name; - void *storage; /* ring buffer: capacity * item_size bytes, aligned to EMB_MSGQ_ALIGN */ + void *ring; /* ring buffer: capacity * item_size bytes, aligned to EMB_MSGQ_ALIGN */ + size_t ring_size; /* bytes at ring; init refuses an undersized ring (A43) */ uint16_t item_size; /* 1 .. CONFIG_EMB_MSGQ_MAX_ITEM (default 64) */ - uint16_t capacity; /* >= 1 */ - uint8_t flags; /* EMB_OBJ_ABORT_WAITERS | EMB_OBJ_FIFO | EMB_MSGQ_OWNERSHIP */ - emb_pool_t pool; /* EMB_MSGQ_OWNERSHIP: the pool whose blocks travel in this queue */ -} emb_msgq_attr_t; + uint16_t capacity; /* >= 1; <= 255 with CONFIG_EMB_IPC_SMALL (A45) */ + uint8_t flags; /* EMB_OBJ_ABORT_WAITERS | EMB_OBJ_FIFO */ + uint8_t reserved_[3]; +} emb_msgq_attr_t; /* EMB_MSGQ_OWNERSHIP and pool arrive with buffer pools (A43) */ +#define EMB_MSGQ_RING_SIZE(item_size, capacity) #define EMB_MSGQ_RING(name, item_size, capacity) /* declares aligned storage of the right size */ +void emb_msgq_attr_default(emb_msgq_attr_t *out); + emb_status_t emb_msgq_init(emb_msgq_storage_t *storage, const emb_msgq_attr_t *attr, emb_msgq_t *out); /* @ctx prekernel thread */ emb_status_t emb_msgq_destroy(emb_msgq_t q); emb_status_t emb_msgq_send(emb_msgq_t q, const void *item, emb_timeout_t timeout); /* @ctx thread; thread isr with EMB_NO_WAIT @time O(1) + copy */ @@ -47,16 +51,19 @@ emb_status_t emb_msgq_peek(emb_msgq_t q, void *out_item); uint16_t emb_msgq_count(emb_msgq_t q); /* @ctx thread isr snapshot */ uint16_t emb_msgq_free(emb_msgq_t q); emb_status_t emb_msgq_bind_notify(emb_msgq_t q, emb_thread_t t, emb_notify_bits_t bit); /* receive side: empty -> non-empty */ +emb_status_t emb_msgq_unbind_notify(emb_msgq_t q); ``` +`send`, `receive`, `send_front` and `peek` are `thread isr`, not `prekernel`; the `_until` forms are `thread` (A47). + ### 2.2 Semantics - **Storage.** The ring holds `capacity` items of `item_size` bytes; `EMB_MSGQ_RING()` declares it with the alignment the kernel needs for word copies. `item_size` above `CONFIG_EMB_MSGQ_MAX_ITEM` is `EMB_EINVAL`: large payloads travel as block pointers (§4), never as large copies. - **Two wait queues**: receivers (when empty) and senders (when full), both with the object's wait policy (`PRIORITY_FIFO` default, `EMB_OBJ_FIFO` optional). - **Send.** If a receiver waits: copy the item directly into the receiver's `out_item` and wake it with hand-off (the ring is untouched; no binding bit, SPEC-004 §6.3). Else if the ring has room: copy into the tail slot; if the ring was empty, signal the binding. Else: block on the senders' queue; the receiver that frees a slot copies this sender's item into the freed slot and wakes it (slot hand-off), so a woken sender never re-checks. The copy happens inside the object's critical section; its cost is bounded by `CONFIG_EMB_MSGQ_MAX_ITEM` and is part of the masked-time budget (R-003 T7). -- **Send to front.** Writes at the head (the next item received) and never blocks (`EMB_ETIMEDOUT` when full); for urgent messages (03 §6.3). -- **Receive.** If the ring is non-empty: copy the head; then, if a sender waits, move its item into the freed tail slot and wake it. Else if a sender waits with the ring empty (capacity reached zero is impossible; this branch is for `capacity` consumed by `send_front` races and is kept for completeness): hand the sender's item directly. Else block on the receivers' queue; the hand-off delivers the item into `out_item` before the wake. -- **Peek** copies the head without removing it; `count` and `free` are snapshots. +- **Send to front.** Hands the item to a waiting receiver like `send`, else writes at the head (the next item received); it takes no timeout, never blocks and is not a cancellation point (`EMB_ETIMEDOUT` when full); for urgent messages (03 §6.3, A44). +- **Receive.** If the ring is non-empty: copy the head; then, if a sender waits, move its item into the freed tail slot and wake it. The queue is never observed empty in that section, so the refill does not signal the binding (A46). Else block on the receivers' queue; the hand-off delivers the item into `out_item` before the wake. Senders wait only while the ring is full and the capacity is at least 1, so no sender can wait while the ring is empty (A44). +- **Peek** copies the head without removing it and returns `EMB_ETIMEDOUT` when the queue is empty (A44); `count` and `free` are snapshots. - **Ownership queues.** With `EMB_MSGQ_OWNERSHIP`, items are block pointers of `pool`: `send` transfers the block's ownership to the queue and `receive` assigns it to the receiver (§4.3). In checked builds the owner field of the block is checked and updated; release builds copy the pointer. - **Destroy** follows ADR-020: waiters wake with `EMB_EDESTROYED` only with `EMB_OBJ_ABORT_WAITERS`; items still in the ring are simply dropped (with `OWNERSHIP`, their blocks are returned to the pool). - ISR callers use `EMB_NO_WAIT` (SPEC-001 §5.2); a full queue returns `EMB_ETIMEDOUT`, never drops silently (RIOT's `msg_try_send` drop is the behavior to avoid, R-001 §6). @@ -170,7 +177,7 @@ A port is the cross-partition and cross-core message endpoint of 03 §6.6. It is ## 8. Observability -Trace events: `msgq_send(q, handed_to | slot)`, `msgq_receive(q, from_slot | handed_from)`, `msgq_full_block(q, thread)`, `pipe_write(p, n)`, `pipe_read(p, n)`, `pipe_close(p)`, `pool_alloc(p, block, thread)`, `pool_free(p, block)`, `pool_transfer(p, block, to)`. Statistics (optional): per queue and pipe, high-water occupancy, blocked-send count; per pool, minimum availability and blocks outstanding; the debug descriptor exports the ring and free-list layout so a debugger can list a queue's contents and a pool's owners. +Trace events: `msgq_send(q, handed_to | slot)`, `msgq_receive(q, from_slot | handed_from)`, `msgq_full_block(q, thread)` (implemented as `EMB_TRACE_MSGQ_SEND` and `EMB_TRACE_MSGQ_RECEIVE` records with the caller, queue and status of every outcome, the hand-off visible as the partner's wake record, and the generic block record, A47), `pipe_write(p, n)`, `pipe_read(p, n)`, `pipe_close(p)`, `pool_alloc(p, block, thread)`, `pool_free(p, block)`, `pool_transfer(p, block, to)`. Statistics (optional): per queue and pipe, high-water occupancy, blocked-send count; per pool, minimum availability and blocks outstanding; the debug descriptor exports the ring and free-list layout so a debugger can list a queue's contents and a pool's owners. ## 9. Tiny profile diff --git a/docs/user/feature-status.md b/docs/user/feature-status.md index 7191b4e..aed2e6e 100644 --- a/docs/user/feature-status.md +++ b/docs/user/feature-status.md @@ -6,9 +6,9 @@ This guide describes the current 0.2 development implementation. The authoritati |---|---|---| | Start and coordinate threads | Static storage, priorities, start, sleep, join/detach, cooperative cancellation | Isolated partitions, budgets, restart, SMP | | Protect or signal work | Mutexes, semaphores, per-thread notifications, event flags, condition variables | Barriers | -| Transfer payloads | Application-owned data with an explicit synchronization protocol | Message queues, pipes, buffer pools | +| Transfer payloads | Copy-by-value message queues; application-owned data with an explicit synchronization protocol | Pipes, buffer pools, zero-copy ownership queues | | Run periodic work | A thread using absolute sleep deadlines | Software timers and system work queues | -| Wait on several readiness sources | Semaphore and event flag bindings, with unbind, plus notification bits and rechecking; a bound thread cannot be destroyed | Message queue bindings, restart-safe target lifetime | +| Wait on several readiness sources | Semaphore, event flag and message queue bindings, with unbind, plus notification bits and rechecking; a bound thread cannot be destroyed | Pipe and pool bindings, restart-safe target lifetime | | Diagnose failures | State/priority queries, optional stack estimates, synchronous trace and fault hooks | Retained crash formats, CTF rings, full host tooling | | Develop without a board | Native Linux/POSIX virtual time and conformance suite | Physical-board timing, peripheral drivers and HIL evidence | | Deploy a controller platform | Embedded kernel ports and QEMU configurations | Complete board/peripheral/application integration and release qualification | diff --git a/docs/user/synchronization.md b/docs/user/synchronization.md index 1385465..11be62c 100644 --- a/docs/user/synchronization.md +++ b/docs/user/synchronization.md @@ -163,3 +163,35 @@ EMB_CHECK(emb_mutex_unlock(lock)); ``` The waiter relocks the mutex before every return, including timeouts, cancellation and destruction, so the relock can take longer than the timeout. The relock is not a cancellation point. A signal with no waiter is lost, and there are no spurious wakeups, but recheck the predicate anyway: another thread can change it between the signal and the relock. The caller must hold the mutex exactly once; a recursively held mutex returns `EMB_ESTATE`. `emb_cond_signal()` wakes the highest-priority waiter and `emb_cond_broadcast()` wakes all of them. Both are thread-only. + +## Message queues + +A message queue (`CONFIG_EMB_MSGQ`) carries fixed-size items by value through a ring that you provide. Declare the ring with `EMB_MSGQ_RING()`, which gives it the size and alignment the kernel checks: + +```c +typedef struct { + uint8_t channel; + uint16_t sample; +} reading_t; + +static emb_msgq_storage_t readings_storage; +static EMB_MSGQ_RING(readings_ring, sizeof(reading_t), 8); +static emb_msgq_t readings; + +emb_msgq_attr_t attr; +emb_msgq_attr_default(&attr); +attr.ring = readings_ring; +attr.ring_size = sizeof(readings_ring); +attr.item_size = sizeof(reading_t); +attr.capacity = 8; +EMB_CHECK(emb_msgq_init(&readings_storage, &attr, &readings)); + +/* a producer thread, or an interrupt with EMB_NO_WAIT: */ +reading_t r = {.channel = 2, .sample = 517}; +emb_status_t st = emb_msgq_send(readings, &r, EMB_NO_WAIT); /* EMB_ETIMEDOUT when full */ +/* a consumer: */ +reading_t got; +st = emb_msgq_receive(readings, &got, EMB_TIMEOUT(EMB_MS(100))); +``` + +Items are received in send order, except that `emb_msgq_send_front()` puts an urgent item at the head. A send to a queue with a waiting receiver copies straight into that receiver's buffer. A receive from a full queue with a waiting sender moves that sender's item into the freed slot before the sender wakes, so neither side re-checks. A full queue never drops an item silently: `send` waits or returns `EMB_ETIMEDOUT`. Interrupts may call `send`, `send_front`, `receive` and `peek` with `EMB_NO_WAIT` only. Each copy runs with interrupts masked, so `CONFIG_EMB_MSGQ_MAX_ITEM` (64 bytes, 16 on tiny) bounds the item size; pass larger payloads by pointer under a protocol of your own until buffer pools exist. Destroying a queue drops its items, and with `EMB_OBJ_ABORT_WAITERS` wakes both receivers and senders with `EMB_EDESTROYED`. A bound notification fires when the queue goes from empty to nonempty, not when an item is handed to a waiting receiver; bind and unbind follow the semaphore rules above. diff --git a/include/emb/api_ids.h b/include/emb/api_ids.h index 2048f1d..dab2011 100644 --- a/include/emb/api_ids.h +++ b/include/emb/api_ids.h @@ -105,5 +105,18 @@ #define EMB_API_ID_COND_WAIT_UNTIL 0x0063u #define EMB_API_ID_COND_SIGNAL 0x0064u #define EMB_API_ID_COND_BROADCAST 0x0065u +#define EMB_API_ID_MSGQ_ATTR_DEFAULT 0x0066u +#define EMB_API_ID_MSGQ_INIT 0x0067u +#define EMB_API_ID_MSGQ_DESTROY 0x0068u +#define EMB_API_ID_MSGQ_SEND 0x0069u +#define EMB_API_ID_MSGQ_SEND_UNTIL 0x006Au +#define EMB_API_ID_MSGQ_SEND_FRONT 0x006Bu +#define EMB_API_ID_MSGQ_RECEIVE 0x006Cu +#define EMB_API_ID_MSGQ_RECEIVE_UNTIL 0x006Du +#define EMB_API_ID_MSGQ_PEEK 0x006Eu +#define EMB_API_ID_MSGQ_COUNT 0x006Fu +#define EMB_API_ID_MSGQ_FREE 0x0070u +#define EMB_API_ID_MSGQ_BIND_NOTIFY 0x0071u +#define EMB_API_ID_MSGQ_UNBIND_NOTIFY 0x0072u #endif /* EMB_API_IDS_H */ diff --git a/include/emb/emb.h b/include/emb/emb.h index 7aba1b6..fe257d3 100644 --- a/include/emb/emb.h +++ b/include/emb/emb.h @@ -16,6 +16,9 @@ #include #include #include +#if CONFIG_EMB_MSGQ +#include +#endif #include #include #include diff --git a/include/emb/msgq.h b/include/emb/msgq.h new file mode 100644 index 0000000..7698802 --- /dev/null +++ b/include/emb/msgq.h @@ -0,0 +1,309 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* Message queues (SPEC-007 §2): fixed-size items copied by value through a caller's ring. */ +#ifndef EMB_MSGQ_H +#define EMB_MSGQ_H + +#include +#include +#include +#include +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +EMB_DECLARE_HANDLE(emb_msgq_t); + +/* Defined by the generated . */ +typedef union emb_msgq_storage emb_msgq_storage_t; + +/* Ring alignment: the kernel may copy items in words. */ +#define EMB_MSGQ_ALIGN sizeof(void *) + +/* Bytes of a ring that holds @capacity items of @item_size bytes. */ +#define EMB_MSGQ_RING_SIZE(item_size, capacity) ((size_t)(item_size) * (size_t)(capacity)) + +/* Declares a correctly sized and aligned ring: static EMB_MSGQ_RING(ring, sizeof(T), 8); */ +#define EMB_MSGQ_RING(name, item_size, capacity) \ + unsigned char name[EMB_MSGQ_RING_SIZE(item_size, capacity)] EMB_ALIGNED(EMB_MSGQ_ALIGN) + +typedef struct emb_msgq_attr { + const char *name; + void *ring; /* caller-owned, aligned to EMB_MSGQ_ALIGN; see EMB_MSGQ_RING() */ + size_t ring_size; /* bytes at @ring: at least capacity * item_size */ + uint16_t item_size; /* 1 .. CONFIG_EMB_MSGQ_MAX_ITEM */ + uint16_t capacity; /* items, at least 1; at most 255 with CONFIG_EMB_IPC_SMALL */ + uint8_t flags; /* EMB_OBJ_ABORT_WAITERS | EMB_OBJ_FIFO */ + uint8_t reserved_[3]; /* explicit padding */ +} emb_msgq_attr_t; + +/** + * emb_msgq_attr_default() - Fill message queue attributes with defaults. + * @out_attr: Receives name and ring NULL and every number 0, which init refuses until the + * ring, its size, the item size and the capacity are set; NULL is a no-op. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_MSGQ + * @req API-020 KRN-IPC-010 + * @since 0.3 + * @stable no + */ +void emb_msgq_attr_default(emb_msgq_attr_t *out_attr); + +/** + * emb_msgq_init() - Construct a message queue in caller-provided storage. + * @storage: Aligned caller-owned storage, never initialized or destroyed; retain it until + * successful destruction. + * @attr: Required attributes, copied; the ring stays borrowed. Keep name storage alive. + * @out_msgq: Required output handle; cleared before argument validation. + * + * @ctx prekernel thread + * @blocks no + * @time O(1); O(active message queues) list lookup with CONFIG_EMB_OBJ_LIST + * @owns borrows:storage borrows:ring + * @config CONFIG_EMB_MSGQ + * @req API-021 KRN-IPC-002 KRN-IPC-010 KRN-IPC-011 KRN-IPC-019 KRN-OBJ-003 KRN-OBJ-007 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EINVAL for bad storage or output, a missing, misaligned or + * undersized ring, an item size of 0 or above CONFIG_EMB_MSGQ_MAX_ITEM, or a + * capacity of 0 (or above 255 with CONFIG_EMB_IPC_SMALL); EMB_EBUSY when @storage + * still holds a message queue that was not destroyed (misuse; exact with + * CONFIG_EMB_OBJ_LIST). Every failure is misuse. + */ +emb_status_t emb_msgq_init(emb_msgq_storage_t *storage, const emb_msgq_attr_t *attr, + emb_msgq_t *out_msgq); + +/** + * emb_msgq_destroy() - End the message queue (KRN-OBJ-001); stored items are dropped. + * @msgq: Initialized message queue; no waiters unless ABORT_WAITERS is set. + * + * @ctx thread + * @blocks no + * @time O(waiters) with EMB_OBJ_ABORT_WAITERS + * @owns gives:storage gives:ring + * @config CONFIG_EMB_MSGQ + * @req KRN-OBJ-001 KRN-NOTIF-014 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EBUSY with waiters and no ABORT_WAITERS (misuse). Receivers and + * senders both leave with EMB_EDESTROYED; a binding is removed and its target + * released. + */ +emb_status_t emb_msgq_destroy(emb_msgq_t msgq); + +/** + * emb_msgq_send() - Copy an item to a waiting receiver, else into the tail, else wait. + * @msgq: Initialized message queue. + * @item: item_size bytes to copy; read until the call returns. + * @timeout: EMB_NO_WAIT, EMB_WAIT_FOREVER or EMB_TIMEOUT(duration); EMB_NO_WAIT only in + * an ISR. + * + * @ctx thread isr + * @blocks timeout + * @time O(1) plus an item copy; O(waiters + timeouts) finite wait enqueue + * @owns none + * @config CONFIG_EMB_MSGQ + * @req KRN-IPC-010 KRN-IPC-011 KRN-IPC-003 KRN-IPC-005 KRN-IPC-018 KRN-WAIT-012 + * @since 0.3 + * @stable no + * + * A waiting receiver gets the item directly and the ring is untouched (no binding + * signal). A sender that waits for a slot is completed by the receive that frees it: its + * item is moved into the ring before it wakes, and it does not re-check. + * + * Return: EMB_OK; EMB_ETIMEDOUT when the queue stayed full (never a silent drop); + * EMB_ECANCELED; EMB_EDESTROYED; EMB_EINVAL for a NULL @item or a bad handle; + * EMB_EPERM (misuse) for a wait from an ISR or before the kernel starts. + */ +emb_status_t emb_msgq_send(emb_msgq_t msgq, const void *item, emb_timeout_t timeout); + +/** + * emb_msgq_send_until() - emb_msgq_send() with an absolute deadline. + * @msgq: Initialized message queue. + * @item: item_size bytes to copy. + * @deadline: Kernel-clock deadline; a past deadline sends only if it need not wait. + * + * @ctx thread + * @blocks timeout + * @time as emb_msgq_send() + * @owns none + * @config CONFIG_EMB_MSGQ + * @req API-024 KRN-TIM-011 KRN-IPC-010 + * @since 0.3 + * @stable no + * + * Return: the statuses of emb_msgq_send(). + */ +emb_status_t emb_msgq_send_until(emb_msgq_t msgq, const void *item, emb_instant_t deadline); + +/** + * emb_msgq_send_front() - Copy an urgent item to a waiting receiver, else to the head. + * @msgq: Initialized message queue. + * @item: item_size bytes to copy. + * + * @ctx thread isr + * @blocks no + * @time O(1) plus an item copy + * @owns none + * @config CONFIG_EMB_MSGQ + * @req KRN-IPC-012 KRN-IPC-003 + * @since 0.3 + * @stable no + * + * The item is the next one received. The call never waits and is not a cancellation + * point. + * + * Return: EMB_OK; EMB_ETIMEDOUT when the queue is full; EMB_EINVAL for a NULL @item or a + * bad handle; EMB_EPERM (misuse) before the kernel starts. + */ +emb_status_t emb_msgq_send_front(emb_msgq_t msgq, const void *item); + +/** + * emb_msgq_receive() - Copy out the head, refilled from a waiting sender, else wait. + * @msgq: Initialized message queue. + * @out_item: Receives item_size bytes on EMB_OK; written by the sender while waiting. + * @timeout: EMB_NO_WAIT, EMB_WAIT_FOREVER or EMB_TIMEOUT(duration); EMB_NO_WAIT only in + * an ISR. + * + * @ctx thread isr + * @blocks timeout + * @time O(1) plus up to two item copies; O(waiters + timeouts) finite wait enqueue + * @owns none + * @config CONFIG_EMB_MSGQ + * @req KRN-IPC-010 KRN-IPC-011 KRN-IPC-003 KRN-IPC-005 KRN-WAIT-012 + * @since 0.3 + * @stable no + * + * When a sender waits, its item fills the freed slot in the same section, so the queue is + * never seen empty and the binding is not signaled. + * + * Return: EMB_OK; EMB_ETIMEDOUT; EMB_ECANCELED; EMB_EDESTROYED; EMB_EINVAL for a NULL + * @out_item or a bad handle; EMB_EPERM (misuse) for a wait from an ISR or before + * the kernel starts. @out_item is written only on EMB_OK. + */ +emb_status_t emb_msgq_receive(emb_msgq_t msgq, void *out_item, emb_timeout_t timeout); + +/** + * emb_msgq_receive_until() - emb_msgq_receive() with an absolute deadline. + * @msgq: Initialized message queue. + * @out_item: Receives item_size bytes on EMB_OK. + * @deadline: Kernel-clock deadline; a past deadline receives only if it need not wait. + * + * @ctx thread + * @blocks timeout + * @time as emb_msgq_receive() + * @owns none + * @config CONFIG_EMB_MSGQ + * @req API-024 KRN-TIM-011 KRN-IPC-010 + * @since 0.3 + * @stable no + * + * Return: the statuses of emb_msgq_receive(). + */ +emb_status_t emb_msgq_receive_until(emb_msgq_t msgq, void *out_item, emb_instant_t deadline); + +/** + * emb_msgq_peek() - Copy the head without removing it. + * @msgq: Initialized message queue. + * @out_item: Receives item_size bytes on EMB_OK. + * + * @ctx thread isr + * @blocks no + * @time O(1) plus an item copy + * @owns none + * @config CONFIG_EMB_MSGQ + * @req KRN-IPC-010 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_ETIMEDOUT when the queue is empty; EMB_EINVAL for a NULL @out_item + * or a bad handle; EMB_EPERM (misuse) before the kernel starts. + */ +emb_status_t emb_msgq_peek(emb_msgq_t msgq, void *out_item); + +/** + * emb_msgq_count() - Snapshot of the items stored; 0 for a bad handle. + * @msgq: Initialized message queue. + * + * @ctx thread isr + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_MSGQ + * @req KRN-IPC-010 + * @since 0.3 + * @stable no + */ +uint16_t emb_msgq_count(emb_msgq_t msgq); + +/** + * emb_msgq_free() - Snapshot of the free slots; 0 for a bad handle. + * @msgq: Initialized message queue. + * + * @ctx thread isr + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_MSGQ + * @req KRN-IPC-010 + * @since 0.3 + * @stable no + */ +uint16_t emb_msgq_free(emb_msgq_t msgq); + +#if CONFIG_EMB_NOTIFY +/** + * emb_msgq_bind_notify() - Set @bit in @thread when the queue goes from empty to nonempty. + * @msgq: Initialized message queue with no binding. + * @thread: Initialized, not terminated, application thread; it cannot be destroyed while + * bound (ADR-046). + * @bit: One application-bit mask, e.g. EMB_NOTIFY_BIT(0). + * + * @ctx prekernel thread + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_MSGQ CONFIG_EMB_NOTIFY + * @req KRN-IPC-018 KRN-NOTIF-004 KRN-NOTIF-005 KRN-NOTIF-014 + * @since 0.3 + * @stable no + * + * Return: the statuses of emb_sem_bind_notify(). An item handed directly to a waiting + * receiver does not signal the binding. + */ +emb_status_t emb_msgq_bind_notify(emb_msgq_t msgq, emb_thread_t thread, emb_notify_bits_t bit); + +/** + * emb_msgq_unbind_notify() - Remove the message queue's notification binding. + * @msgq: Initialized message queue. + * + * @ctx prekernel thread + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_MSGQ CONFIG_EMB_NOTIFY + * @req KRN-NOTIF-013 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_ENOENT when not bound; EMB_EINVAL for a bad handle. + */ +emb_status_t emb_msgq_unbind_notify(emb_msgq_t msgq); +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* EMB_MSGQ_H */ diff --git a/include/emb/trace.h b/include/emb/trace.h index be694fc..85f1406 100644 --- a/include/emb/trace.h +++ b/include/emb/trace.h @@ -45,12 +45,14 @@ enum emb_trace_id { EMB_TRACE_PI_DEPTH = 26, /* a0 = index, a1 = depth reached */ EMB_TRACE_IDLE_ENTER = 27, EMB_TRACE_IDLE_EXIT = 28, - EMB_TRACE_TICK = 29, /* a0 = ticks low word */ - EMB_TRACE_SLEEP = 30, /* a0 = index, a1 = deadline low word */ - EMB_TRACE_EVENT_SET = 31, /* a0 = waiters woken, a1 = event address, a2 = bits after */ - EMB_TRACE_EVENT_WAIT = 32, /* a0 = index, a1 = event address, a2 = status */ - EMB_TRACE_COND_WAIT = 33, /* a0 = index, a1 = cond address, a2 = status after the relock */ - EMB_TRACE_COND_SIGNAL = 34 /* a0 = waiters woken, a1 = cond address, a2 = 1 for broadcast */ + EMB_TRACE_TICK = 29, /* a0 = ticks low word */ + EMB_TRACE_SLEEP = 30, /* a0 = index, a1 = deadline low word */ + EMB_TRACE_EVENT_SET = 31, /* a0 = waiters woken, a1 = event address, a2 = bits after */ + EMB_TRACE_EVENT_WAIT = 32, /* a0 = index, a1 = event address, a2 = status */ + EMB_TRACE_COND_WAIT = 33, /* a0 = index, a1 = cond address, a2 = status after the relock */ + EMB_TRACE_COND_SIGNAL = 34, /* a0 = waiters woken, a1 = cond address, a2 = 1 for broadcast */ + EMB_TRACE_MSGQ_SEND = 35, /* a0 = index, a1 = queue address, a2 = status (send, front) */ + EMB_TRACE_MSGQ_RECEIVE = 36 /* a0 = index, a1 = queue address, a2 = status */ }; typedef struct emb_trace_event { diff --git a/kernel/CMakeLists.txt b/kernel/CMakeLists.txt index 2349a92..a33397e 100644 --- a/kernel/CMakeLists.txt +++ b/kernel/CMakeLists.txt @@ -30,6 +30,9 @@ endif() if(CONFIG_EMB_COND) list(APPEND EMB_KERNEL_SOURCES sync/cond.c) endif() +if(CONFIG_EMB_MSGQ) + list(APPEND EMB_KERNEL_SOURCES ipc/msgq.c) +endif() add_library(emb_kernel STATIC ${EMB_KERNEL_SOURCES}) emb_use_kernel_compiler(emb_kernel) diff --git a/kernel/Kconfig b/kernel/Kconfig index 9845ec0..7ebc489 100644 --- a/kernel/Kconfig +++ b/kernel/Kconfig @@ -217,6 +217,30 @@ config EMB_MUTEX_CEILING_STATIC_ONLY endmenu +menu "Inter-thread communication" + +config EMB_MSGQ + bool "Message queues (SPEC-007 §2)" + default y + +config EMB_MSGQ_MAX_ITEM + int "Largest message queue item in bytes (SPEC-007 §2.2)" + depends on EMB_MSGQ + default 16 if EMB_PROFILE_TINY + default 64 + range 1 255 if EMB_IPC_SMALL + range 1 1024 + help + Bounds the copy each send or receive makes with interrupts masked (KRN-IPC-011). + Larger payloads belong in a buffer pool, passed by pointer. + +config EMB_IPC_SMALL + bool "8-bit IPC counts: at most 255 items per queue (KRN-IPC-021)" + default y if EMB_PROFILE_TINY + default n + +endmenu + menu "Objects" choice EMB_HANDLE_MODEL_CHOICE diff --git a/kernel/include/embk/ipc.h b/kernel/include/embk/ipc.h new file mode 100644 index 0000000..1d13ee3 --- /dev/null +++ b/kernel/include/embk/ipc.h @@ -0,0 +1,41 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* Inter-thread communication objects (SPEC-007): message queues. */ +#ifndef EMBK_IPC_H +#define EMBK_IPC_H + +#include + +#include +#include + +#if CONFIG_EMB_MSGQ +#if CONFIG_EMB_IPC_SMALL +typedef uint8_t embk_ipc_count_t; /* 8-bit counts on small targets (KRN-IPC-021) */ +#else +typedef uint16_t embk_ipc_count_t; +#endif + +typedef struct embk_msgq { + embk_obj_t obj; /* obj.flags: EMB_OBJ_ABORT_WAITERS | EMB_OBJ_FIFO */ + embk_wait_queue_t receivers; /* wait while empty; wake_data holds the out_item pointer */ + embk_wait_queue_t senders; /* wait while full; wake_data holds the item pointer */ + unsigned char *ring; + embk_ipc_count_t item_size; + embk_ipc_count_t capacity; + embk_ipc_count_t head; /* slot of the next item received */ + embk_ipc_count_t count; /* items stored */ +#if CONFIG_EMB_NOTIFY + embk_binding_t binding; /* receive side: empty to nonempty, never on a hand-off */ +#endif +#if CONFIG_EMB_OBJ_LIST + embk_list_node_t all_node; /* in embk_msgq_all from init to destroy */ +#endif +} embk_msgq_t; + +#if CONFIG_EMB_OBJ_LIST +extern embk_list_t embk_msgq_all; /* every active message queue; lock: scheduler domain */ +#endif +#endif + +#endif /* EMBK_IPC_H */ diff --git a/kernel/include/embk/kernel.h b/kernel/include/embk/kernel.h index 2e96e4c..5c882ac 100644 --- a/kernel/include/embk/kernel.h +++ b/kernel/include/embk/kernel.h @@ -41,6 +41,7 @@ #define EMBK_OBJ_MUTEX ((uint8_t)0x53u) #define EMBK_OBJ_EVENT ((uint8_t)0x54u) #define EMBK_OBJ_COND ((uint8_t)0x55u) +#define EMBK_OBJ_MSGQ ((uint8_t)0x56u) #define EMBK_OBJ_UNINIT ((uint8_t)0u) #define EMBK_OBJ_ACTIVE ((uint8_t)1u) diff --git a/kernel/ipc/msgq.c b/kernel/ipc/msgq.c new file mode 100644 index 0000000..2e6b1d1 --- /dev/null +++ b/kernel/ipc/msgq.c @@ -0,0 +1,402 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* + * Message queues (SPEC-007 §2): fixed-size items copied by value through a caller's ring, + * with a wait queue for receivers (while empty) and one for senders (while full). A send + * copies straight into a waiting receiver's out_item, whose pointer waits in wake_data; a + * receive that frees a slot moves the first waiting sender's item, whose pointer waits in + * wake_data likewise, into the ring. A woken thread never re-checks. Each section copies + * at most two items of at most CONFIG_EMB_MSGQ_MAX_ITEM bytes (KRN-IPC-011). + */ +#include +#include + +#include +#include +#include +#include +#include +#include + +#if CONFIG_EMB_MSGQ + +EMB_STATIC_ASSERT(sizeof(embk_msgq_t) <= EMB_MSGQ_STORAGE_SIZE, "generated storage too small"); +EMB_STATIC_ASSERT(!CONFIG_EMB_IPC_SMALL || CONFIG_EMB_MSGQ_MAX_ITEM <= 255, + "an 8-bit count cannot hold the item size"); + +#if CONFIG_EMB_OBJ_LIST +embk_list_t embk_msgq_all = EMBK_LIST_INITIALIZER(embk_msgq_all); /* usable before kernel init */ +#endif + +static EMB_INLINE embk_msgq_t *msgq_from_handle(emb_msgq_t h) +{ + return (embk_msgq_t *)embk_obj_from_raw(h.raw, EMBK_OBJ_MSGQ, offsetof(embk_msgq_t, obj)); +} + +/* The kernel does not depend on the C library; @n is at most CONFIG_EMB_MSGQ_MAX_ITEM. */ +static void copy_item(void *dst, const void *src, unsigned n) +{ + unsigned char *d = (unsigned char *)dst; + const unsigned char *s = (const unsigned char *)src; + while (n != 0u) { + *d++ = *s++; + n--; + } +} + +/* The slot @offset items after the head; @offset is below the capacity. */ +static unsigned char *slot(const embk_msgq_t *q, unsigned offset) +{ + unsigned to_end = (unsigned)q->capacity - q->head; + unsigned i = (offset < to_end) ? q->head + offset : offset - to_end; + return q->ring + ((size_t)i * q->item_size); +} + +/* Store @item at the tail, or at the head when @front; lock held, a slot free. */ +static void store_locked(embk_msgq_t *q, const void *item, bool front) +{ + if (front) { + q->head = (embk_ipc_count_t)((q->head == 0u) ? q->capacity - 1u : q->head - 1u); + copy_item(slot(q, 0u), item, q->item_size); + } else { + copy_item(slot(q, q->count), item, q->item_size); + } + q->count++; +#if CONFIG_EMB_NOTIFY + if (q->count == 1u) { + embk_binding_signal(&q->binding, q); /* empty to nonempty (KRN-IPC-018) */ + } +#endif +} + +/* The part of a send that never waits; lock held. False when the ring is full. */ +static bool put_locked(embk_msgq_t *q, const void *item, bool front) +{ + embk_thread_t *w = embk_wait_first(&q->receivers); + if (w != NULL) { + copy_item((void *)w->wake_data, item, q->item_size); /* into its out_item */ + (void)embk_wait_wake(w, EMBK_WAKE_SATISFIED, 0u); + return true; + } + if (q->count == q->capacity) { + return false; + } + store_locked(q, item, front); + return true; +} + +/* Copy out the head and refill the freed slot from the first waiting sender; lock held, + * the ring not empty. The ring is never seen empty here, so the binding is not signaled. */ +static void take_locked(embk_msgq_t *q, void *out_item) +{ + embk_thread_t *w; + copy_item(out_item, slot(q, 0u), q->item_size); + q->head = (embk_ipc_count_t)((q->head + 1u == q->capacity) ? 0u : q->head + 1u); + q->count--; + w = embk_wait_first(&q->senders); + if (w != NULL) { + copy_item(slot(q, q->count), (const void *)w->wake_data, q->item_size); + q->count++; + (void)embk_wait_wake(w, EMBK_WAKE_SATISFIED, 0u); + } +} + +void emb_msgq_attr_default(emb_msgq_attr_t *out_attr) +{ + if (out_attr == NULL) { + return; + } + out_attr->name = NULL; + out_attr->ring = NULL; + out_attr->ring_size = 0u; + out_attr->item_size = 0u; + out_attr->capacity = 0u; + out_attr->flags = 0u; + out_attr->reserved_[0] = 0u; + out_attr->reserved_[1] = 0u; + out_attr->reserved_[2] = 0u; +} + +emb_status_t emb_msgq_init(emb_msgq_storage_t *storage, const emb_msgq_attr_t *attr, + emb_msgq_t *out_msgq) +{ + embk_msgq_t *q; +#if CONFIG_EMB_OBJ_LIST + emb_irq_key_t key; +#endif + if (out_msgq != NULL) { + out_msgq->raw = 0u; + } + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_MSGQ_INIT); + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_INIT, storage != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_INIT, attr != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_INIT, out_msgq != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, + 3u); + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_INIT, + ((uintptr_t)storage % (uintptr_t)EMB_MSGQ_STORAGE_ALIGN) == 0u, + EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + /* SPEC-007 §2.2, §7: the ring must hold capacity items; division avoids overflow */ + EMBK_API_REQUIRE( + EMB_API_ID_MSGQ_INIT, + attr->ring != NULL && ((uintptr_t)attr->ring % (uintptr_t)EMB_MSGQ_ALIGN) == 0u && + attr->item_size != 0u && attr->item_size <= CONFIG_EMB_MSGQ_MAX_ITEM && + attr->capacity != 0u && (!CONFIG_EMB_IPC_SMALL || attr->capacity <= 255u) && + attr->ring_size / attr->item_size >= attr->capacity, + EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); + q = (embk_msgq_t *)(void *)storage; +#if CONFIG_EMB_OBJ_LIST + /* storage is reused only through destroy (SPEC-009 §6, KRN-OBJ-007) */ + key = embk_lock_sched(); + if (embk_list_contains(&embk_msgq_all, &q->all_node)) { + embk_unlock_sched(key); + EMBK_API_MISUSE(EMB_API_ID_MSGQ_INIT, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); + } +#endif + embk_obj_init(&q->obj, EMBK_OBJ_MSGQ, attr->flags & (EMB_OBJ_ABORT_WAITERS | EMB_OBJ_FIFO), + attr->name); + embk_wait_queue_init(&q->receivers, ((attr->flags & EMB_OBJ_FIFO) != 0u) ? EMBK_WAIT_FIFO : 0u); + embk_wait_queue_init(&q->senders, ((attr->flags & EMB_OBJ_FIFO) != 0u) ? EMBK_WAIT_FIFO : 0u); + q->ring = (unsigned char *)attr->ring; + q->item_size = (embk_ipc_count_t)attr->item_size; + q->capacity = (embk_ipc_count_t)attr->capacity; + q->head = 0u; + q->count = 0u; +#if CONFIG_EMB_NOTIFY + embk_binding_init(&q->binding); +#endif +#if CONFIG_EMB_OBJ_LIST + embk_list_append(&embk_msgq_all, &q->all_node); + embk_unlock_sched(key); +#endif + out_msgq->raw = embk_obj_to_raw(q); + return EMB_OK; +} + +emb_status_t emb_msgq_destroy(emb_msgq_t msgq) +{ + embk_msgq_t *q = msgq_from_handle(msgq); + emb_irq_key_t key; + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_MSGQ_DESTROY); + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_DESTROY, q != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + key = embk_lock_object(q); + if (!embk_wait_queue_is_empty(&q->receivers) || !embk_wait_queue_is_empty(&q->senders)) { + if ((q->obj.flags & EMB_OBJ_ABORT_WAITERS) == 0u) { + embk_unlock_object(q, key); + EMBK_API_MISUSE(EMB_API_ID_MSGQ_DESTROY, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); + } + (void)embk_wait_flush(&q->receivers, EMBK_WAKE_DESTROYED); + (void)embk_wait_flush(&q->senders, EMBK_WAKE_DESTROYED); + } +#if CONFIG_EMB_NOTIFY + (void)embk_binding_unbind(&q->binding); /* releases the target */ +#endif +#if CONFIG_EMB_OBJ_LIST + embk_list_remove(&q->all_node); +#endif + q->count = 0u; /* stored items are dropped (SPEC-007 §2.2) */ + q->obj.state = EMBK_OBJ_UNINIT; + embk_sched_reschedule_if_needed(); + embk_unlock_object(q, key); + return EMB_OK; +} + +/* Send and receive run in a thread, or in an ISR with EMB_NO_WAIT (SPEC-007 §1). */ +static EMB_INLINE bool msgq_context_ok(void) +{ + return embk_in_thread() || embk_in_isr(); +} + +static emb_status_t send_common(uint16_t api_id, embk_msgq_t *q, const void *item, + emb_tick_t deadline, bool nowait) +{ + embk_thread_t *self; + emb_irq_key_t key; + embk_wait_result_t r; + uintptr_t data; + EMBK_API_REQUIRE(api_id, msgq_context_ok(), EMB_FAULT_API_CONTEXT, EMB_EPERM, 0u); + self = embk_cpu.current; + if (q == NULL) { + EMBK_TRACE(EMB_TRACE_MSGQ_SEND, embk_thread_index(self), 0u, EMB_EINVAL); + EMBK_API_MISUSE(api_id, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + } + EMBK_API_REQUIRE(api_id, item != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); + EMBK_API_REQUIRE_CAN_BLOCK(api_id, nowait ? 0u : 1u); + key = embk_lock_object(q); + if (!embk_in_isr() && embk_thread_take_cancel_locked(self)) { + EMBK_TRACE(EMB_TRACE_MSGQ_SEND, embk_thread_index(self), q, EMB_ECANCELED); + embk_unlock_object(q, key); + return EMB_ECANCELED; + } + if (put_locked(q, item, false)) { + EMBK_TRACE(EMB_TRACE_MSGQ_SEND, embk_thread_index(self), q, EMB_OK); + embk_sched_reschedule_if_needed(); + embk_unlock_object(q, key); + return EMB_OK; + } + if (nowait) { + EMBK_TRACE(EMB_TRACE_MSGQ_SEND, embk_thread_index(self), q, EMB_ETIMEDOUT); + embk_unlock_object(q, key); + return EMB_ETIMEDOUT; + } + self->wake_data = (uintptr_t)item; /* until the receive that frees a slot copies it */ + r = embk_block_on(&q->senders, EMB_WAIT_REASON_OBJECT, deadline, key, &data); + (void)data; + EMBK_TRACE(EMB_TRACE_MSGQ_SEND, embk_thread_index(self), q, embk_wait_result_status(r)); + return embk_wait_result_status(r); +} + +emb_status_t emb_msgq_send(emb_msgq_t msgq, const void *item, emb_timeout_t timeout) +{ + return send_common(EMB_API_ID_MSGQ_SEND, msgq_from_handle(msgq), item, + embk_deadline_from_timeout(timeout), EMB_TIMEOUT_IS_NO_WAIT(timeout)); +} + +emb_status_t emb_msgq_send_until(emb_msgq_t msgq, const void *item, emb_instant_t deadline) +{ + bool nowait = deadline.ticks != EMB_TICK_MAX && embk_deadline_passed(deadline.ticks); + EMBK_API_REQUIRE_THREAD(EMB_API_ID_MSGQ_SEND_UNTIL); + return send_common(EMB_API_ID_MSGQ_SEND_UNTIL, msgq_from_handle(msgq), item, deadline.ticks, + nowait); +} + +emb_status_t emb_msgq_send_front(emb_msgq_t msgq, const void *item) +{ + embk_msgq_t *q = msgq_from_handle(msgq); + emb_irq_key_t key; + emb_status_t st; + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_SEND_FRONT, msgq_context_ok(), EMB_FAULT_API_CONTEXT, + EMB_EPERM, 0u); + if (q == NULL) { + EMBK_TRACE(EMB_TRACE_MSGQ_SEND, embk_thread_index(embk_cpu.current), 0u, EMB_EINVAL); + EMBK_API_MISUSE(EMB_API_ID_MSGQ_SEND_FRONT, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + } + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_SEND_FRONT, item != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, + 2u); + key = embk_lock_object(q); + st = put_locked(q, item, true) ? EMB_OK : EMB_ETIMEDOUT; + EMBK_TRACE(EMB_TRACE_MSGQ_SEND, embk_thread_index(embk_cpu.current), q, st); + embk_sched_reschedule_if_needed(); + embk_unlock_object(q, key); + return st; +} + +static emb_status_t receive_common(uint16_t api_id, embk_msgq_t *q, void *out_item, + emb_tick_t deadline, bool nowait) +{ + embk_thread_t *self; + emb_irq_key_t key; + embk_wait_result_t r; + uintptr_t data; + EMBK_API_REQUIRE(api_id, msgq_context_ok(), EMB_FAULT_API_CONTEXT, EMB_EPERM, 0u); + self = embk_cpu.current; + if (q == NULL) { + EMBK_TRACE(EMB_TRACE_MSGQ_RECEIVE, embk_thread_index(self), 0u, EMB_EINVAL); + EMBK_API_MISUSE(api_id, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + } + EMBK_API_REQUIRE(api_id, out_item != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); + EMBK_API_REQUIRE_CAN_BLOCK(api_id, nowait ? 0u : 1u); + key = embk_lock_object(q); + if (!embk_in_isr() && embk_thread_take_cancel_locked(self)) { + EMBK_TRACE(EMB_TRACE_MSGQ_RECEIVE, embk_thread_index(self), q, EMB_ECANCELED); + embk_unlock_object(q, key); + return EMB_ECANCELED; + } + if (q->count != 0u) { + take_locked(q, out_item); + EMBK_TRACE(EMB_TRACE_MSGQ_RECEIVE, embk_thread_index(self), q, EMB_OK); + embk_sched_reschedule_if_needed(); + embk_unlock_object(q, key); + return EMB_OK; + } + if (nowait) { + EMBK_TRACE(EMB_TRACE_MSGQ_RECEIVE, embk_thread_index(self), q, EMB_ETIMEDOUT); + embk_unlock_object(q, key); + return EMB_ETIMEDOUT; + } + self->wake_data = (uintptr_t)out_item; /* the sender copies into it before the wake */ + r = embk_block_on(&q->receivers, EMB_WAIT_REASON_OBJECT, deadline, key, &data); + (void)data; + EMBK_TRACE(EMB_TRACE_MSGQ_RECEIVE, embk_thread_index(self), q, embk_wait_result_status(r)); + return embk_wait_result_status(r); +} + +emb_status_t emb_msgq_receive(emb_msgq_t msgq, void *out_item, emb_timeout_t timeout) +{ + return receive_common(EMB_API_ID_MSGQ_RECEIVE, msgq_from_handle(msgq), out_item, + embk_deadline_from_timeout(timeout), EMB_TIMEOUT_IS_NO_WAIT(timeout)); +} + +emb_status_t emb_msgq_receive_until(emb_msgq_t msgq, void *out_item, emb_instant_t deadline) +{ + bool nowait = deadline.ticks != EMB_TICK_MAX && embk_deadline_passed(deadline.ticks); + EMBK_API_REQUIRE_THREAD(EMB_API_ID_MSGQ_RECEIVE_UNTIL); + return receive_common(EMB_API_ID_MSGQ_RECEIVE_UNTIL, msgq_from_handle(msgq), out_item, + deadline.ticks, nowait); +} + +emb_status_t emb_msgq_peek(emb_msgq_t msgq, void *out_item) +{ + embk_msgq_t *q = msgq_from_handle(msgq); + emb_irq_key_t key; + emb_status_t st = EMB_ETIMEDOUT; + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_PEEK, msgq_context_ok(), EMB_FAULT_API_CONTEXT, EMB_EPERM, 0u); + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_PEEK, q != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_PEEK, out_item != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, + 2u); + key = embk_lock_object(q); + if (q->count != 0u) { + copy_item(out_item, slot(q, 0u), q->item_size); + st = EMB_OK; + } + embk_unlock_object(q, key); + return st; +} + +uint16_t emb_msgq_count(emb_msgq_t msgq) +{ + const embk_msgq_t *q = msgq_from_handle(msgq); + return (q == NULL) ? 0u : q->count; +} + +uint16_t emb_msgq_free(emb_msgq_t msgq) +{ + const embk_msgq_t *q = msgq_from_handle(msgq); + return (q == NULL) ? 0u : (uint16_t)(q->capacity - q->count); +} + +#if CONFIG_EMB_NOTIFY +emb_status_t emb_msgq_bind_notify(emb_msgq_t msgq, emb_thread_t thread, emb_notify_bits_t bit) +{ + embk_msgq_t *q = msgq_from_handle(msgq); + embk_thread_t *t = embk_thread_from_handle(thread); + emb_irq_key_t key; + emb_status_t st; + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_MSGQ_BIND_NOTIFY); + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_BIND_NOTIFY, q != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_BIND_NOTIFY, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 2u); + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_BIND_NOTIFY, + bit != 0u && (bit & (emb_notify_bits_t)(bit - 1u)) == 0u && + (bit & (emb_notify_bits_t)~EMB_NOTIFY_APP_MASK) == 0u, + EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 3u); + key = embk_lock_object(q); + st = embk_binding_bind(&q->binding, t, bit); + embk_unlock_object(q, key); + return st; +} + +emb_status_t emb_msgq_unbind_notify(emb_msgq_t msgq) +{ + embk_msgq_t *q = msgq_from_handle(msgq); + emb_irq_key_t key; + bool bound; + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_MSGQ_UNBIND_NOTIFY); + EMBK_API_REQUIRE(EMB_API_ID_MSGQ_UNBIND_NOTIFY, q != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, + 1u); + key = embk_lock_object(q); + bound = embk_binding_unbind(&q->binding); + embk_unlock_object(q, key); + return bound ? EMB_OK : EMB_ENOENT; +} +#endif + +#endif /* CONFIG_EMB_MSGQ */ diff --git a/kernel/object/storage_probe.c b/kernel/object/storage_probe.c index a0db3bf..d37e888 100644 --- a/kernel/object/storage_probe.c +++ b/kernel/object/storage_probe.c @@ -7,6 +7,7 @@ * (EMB_LAYOUT_MARKER), which tools/storage/gen_storage.py reads from an ELF, Mach-O, * or COFF object alike, for the host or a cross target. */ +#include #include #include @@ -35,4 +36,7 @@ void embk_storage_probe(void) #if CONFIG_EMB_COND PROBE(cond, embk_cond_t); #endif +#if CONFIG_EMB_MSGQ + PROBE(msgq, embk_msgq_t); +#endif } diff --git a/tests/conformance/CMakeLists.txt b/tests/conformance/CMakeLists.txt index cd18daa..8ac0373 100644 --- a/tests/conformance/CMakeLists.txt +++ b/tests/conformance/CMakeLists.txt @@ -26,6 +26,9 @@ endif() if(CONFIG_EMB_COND) emb_add_test(test_cond test_cond.c) endif() +if(CONFIG_EMB_MSGQ) + emb_add_test(test_msgq test_msgq.c) +endif() emb_add_test(test_first_execution test_first_execution.c) if(CONFIG_EMB_ARCH_HAS_FPU_LAZY) emb_add_test(test_fpu test_fpu.c) diff --git a/tests/conformance/test_msgq.c b/tests/conformance/test_msgq.c new file mode 100644 index 0000000..d0b765b --- /dev/null +++ b/tests/conformance/test_msgq.c @@ -0,0 +1,296 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* Message queue conformance (SPEC-007 §2; KRN-IPC-010 to 012, 018, 019). */ +#include + +#define CAP 3u +#define IRQ_Q 2u /* software interrupt slot */ + +static emb_msgq_storage_t storage; +static EMB_MSGQ_RING(ring, sizeof(uint16_t), CAP); +static emb_msgq_t mq; + +static void make(uint16_t capacity, uint8_t flags) +{ + emb_msgq_attr_t attr; + emb_msgq_attr_default(&attr); + attr.ring = ring; + attr.ring_size = sizeof(ring); + attr.item_size = sizeof(uint16_t); + attr.capacity = capacity; + attr.flags = flags; + EMB_ASSERT_OK(emb_msgq_init(&storage, &attr, &mq)); +} + +static uint16_t take(emb_timeout_t timeout) +{ + uint16_t v = 0u; + EMB_ASSERT_OK(emb_msgq_receive(mq, &v, timeout)); + return v; +} + +static void put(uint16_t v) +{ + EMB_ASSERT_OK(emb_msgq_send(mq, &v, EMB_NO_WAIT)); +} + +/* Receives with arg's timeout in ticks (0: forever) and marks id * 100 + value, or id + 500 + * timed out, id + 600 canceled, id + 700 destroyed. arg packs ticks in bits 0 to 7 and the id + * in bits 8 to 11, which a 16-bit uintptr_t (AVR) holds. */ +static void receiver(void *arg) +{ + uintptr_t a = (uintptr_t)arg; + uint32_t ticks = (uint32_t)(a & 0xFFu); + int id = (int)((a >> 8) & 0x0Fu); + uint16_t v = 0u; + emb_status_t st = + emb_msgq_receive(mq, &v, ticks == 0u ? EMB_WAIT_FOREVER : EMB_TIMEOUT(EMB_TICKS(ticks))); + emb_test_mark(st == EMB_OK ? id * 100 + (int)v + : st == EMB_ETIMEDOUT ? id + 500 + : st == EMB_ECANCELED ? id + 600 + : st == EMB_EDESTROYED ? id + 700 + : id + 900); +} + +/* Sends the value id * 10 with arg's timeout and marks id + 10 sent, or the codes above. */ +static void sender(void *arg) +{ + uintptr_t a = (uintptr_t)arg; + uint32_t ticks = (uint32_t)(a & 0xFFu); + int id = (int)((a >> 8) & 0x0Fu); + uint16_t v = (uint16_t)(id * 10); + emb_status_t st = + emb_msgq_send(mq, &v, ticks == 0u ? EMB_WAIT_FOREVER : EMB_TIMEOUT(EMB_TICKS(ticks))); + emb_test_mark(st == EMB_OK ? id + 10 + : st == EMB_ETIMEDOUT ? id + 500 + : st == EMB_ECANCELED ? id + 600 + : st == EMB_EDESTROYED ? id + 700 + : id + 900); +} + +#define ARG(ticks, id) ((void *)(uintptr_t)((ticks) | ((id) << 8))) + +EMB_TEST(msgq_fifo_order_counts_and_peek) +{ + uint16_t v = 0u; + uint16_t full = 9u; + EMB_TEST_REQ("KRN-IPC-010", "KRN-IPC-019"); + make(CAP, 0u); + EMB_ASSERT_STATUS(emb_msgq_peek(mq, &v), EMB_ETIMEDOUT); /* empty */ + EMB_ASSERT_STATUS(emb_msgq_receive(mq, &v, EMB_NO_WAIT), EMB_ETIMEDOUT); + put(1u); + put(2u); + put(3u); + EMB_ASSERT_STATUS(emb_msgq_send(mq, &full, EMB_NO_WAIT), EMB_ETIMEDOUT); /* never dropped */ + EMB_ASSERT_EQ(emb_msgq_count(mq), 3u); + EMB_ASSERT_EQ(emb_msgq_free(mq), 0u); + EMB_ASSERT_OK(emb_msgq_peek(mq, &v)); + EMB_ASSERT_EQ(v, 1u); + EMB_ASSERT_EQ(emb_msgq_count(mq), 3u); /* peek removes nothing */ + EMB_ASSERT_EQ(take(EMB_NO_WAIT), 1u); + put(4u); /* wraps around the ring */ + EMB_ASSERT_EQ(take(EMB_NO_WAIT), 2u); + EMB_ASSERT_EQ(take(EMB_NO_WAIT), 3u); + EMB_ASSERT_EQ(take(EMB_NO_WAIT), 4u); + EMB_ASSERT_STATUS(emb_msgq_receive_until(mq, &v, emb_time_now()), EMB_ETIMEDOUT); + EMB_ASSERT_OK(emb_msgq_send_until(mq, &full, emb_time_now())); /* a slot is free */ + EMB_ASSERT_EQ(emb_msgq_free(mq), 2u); + EMB_ASSERT_OK(emb_msgq_destroy(mq)); /* the stored item is dropped */ +} + +EMB_TEST(msgq_send_front_is_received_first_and_never_waits) +{ + uint16_t v = 7u; + EMB_TEST_REQ("KRN-IPC-012"); + make(2u, 0u); + put(1u); + EMB_ASSERT_OK(emb_msgq_send_front(mq, &v)); + EMB_ASSERT_STATUS(emb_msgq_send_front(mq, &v), EMB_ETIMEDOUT); /* full */ + EMB_ASSERT_EQ(take(EMB_NO_WAIT), 7u); + EMB_ASSERT_EQ(take(EMB_NO_WAIT), 1u); + EMB_ASSERT_OK(emb_msgq_destroy(mq)); +} + +EMB_TEST(msgq_waiting_receivers_get_items_directly_in_priority_order) +{ + EMB_TEST_REQ("KRN-IPC-010", "KRN-IPC-005", "KRN-WAIT-001"); + make(CAP, 0u); + (void)emb_test_thread(EMB_TEST_NAME("r3"), 3u, 0u, receiver, ARG(0u, 3u)); + (void)emb_test_thread(EMB_TEST_NAME("r4"), 4u, 0u, receiver, ARG(0u, 4u)); + put(5u); /* the higher receiver, at once */ + EMB_ASSERT_MARKS(405); + EMB_ASSERT_EQ(emb_msgq_count(mq), 0u); /* the ring was not used */ + put(6u); + EMB_ASSERT_MARKS(405, 306); + EMB_ASSERT_OK(emb_msgq_destroy(mq)); +} + +EMB_TEST(msgq_receive_refills_the_freed_slot_from_a_waiting_sender) +{ + EMB_TEST_REQ("KRN-IPC-010", "KRN-IPC-011"); + make(1u, 0u); + put(1u); + (void)emb_test_thread(EMB_TEST_NAME("s3"), 3u, 0u, sender, ARG(0u, 3u)); /* waits */ + EMB_ASSERT_EQ(emb_msgq_count(mq), 1u); + EMB_ASSERT_EQ(take(EMB_NO_WAIT), 1u); /* its item moves in, and it wakes */ + EMB_ASSERT_MARKS(13); + EMB_ASSERT_EQ(emb_msgq_count(mq), 1u); + EMB_ASSERT_EQ(take(EMB_NO_WAIT), 30u); + EMB_ASSERT_OK(emb_msgq_destroy(mq)); +} + +EMB_TEST(msgq_waits_time_out_and_cancel_without_storing) +{ + emb_thread_t h; + uint32_t t0 = EMB_TEST_NOW(); + EMB_TEST_REQ("KRN-IPC-010", "KRN-WAIT-004", "KRN-WAIT-017"); + make(1u, 0u); + (void)emb_test_thread(EMB_TEST_NAME("r3"), 3u, 0u, receiver, ARG(5u, 3u)); + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(10))); + EMB_ASSERT_MARKS(503); + EMB_ASSERT_ELAPSED(t0, 10u); + put(1u); + h = emb_test_thread(EMB_TEST_NAME("s4"), 4u, 0u, sender, ARG(0u, 4u)); + EMB_ASSERT_OK(emb_thread_cancel(h)); + EMB_ASSERT_MARKS(503, 604); + EMB_ASSERT_EQ(emb_msgq_count(mq), 1u); /* the canceled item was never stored */ + EMB_ASSERT_EQ(take(EMB_NO_WAIT), 1u); + EMB_ASSERT_OK(emb_msgq_destroy(mq)); +} + +EMB_TEST_ISR(isr_send) +{ + uint16_t v = 8u; + (void)emb_isr_arg_; + emb_test_mark(100); + EMB_ASSERT_OK(emb_msgq_send(mq, &v, EMB_NO_WAIT)); +} + +EMB_TEST_ISR(isr_receive) +{ + uint16_t v = 0u; + (void)emb_isr_arg_; + EMB_ASSERT_OK(emb_msgq_receive(mq, &v, EMB_NO_WAIT)); + emb_test_mark(200 + (int)v); +} + +EMB_TEST(msgq_isr_send_and_receive_never_wait) +{ + EMB_TEST_REQ("KRN-IPC-003", "KRN-IPC-010", "KRN-IRQ-010"); + make(1u, 0u); + emb_test_irq_connect(IRQ_Q, isr_send, NULL); + (void)emb_test_thread(EMB_TEST_NAME("r3"), 3u, 0u, receiver, ARG(0u, 3u)); + emb_test_irq_raise(IRQ_Q); /* hands the item to the receiver, which runs at ISR exit */ + emb_test_mark(1); + EMB_ASSERT_MARKS(100, 308, 1); + put(2u); + (void)emb_test_thread(EMB_TEST_NAME("s4"), 4u, 0u, sender, ARG(0u, 4u)); /* waits */ + emb_test_irq_connect(IRQ_Q, isr_receive, NULL); + emb_test_irq_raise(IRQ_Q); /* takes 2 and moves the sender's 40 in */ + EMB_ASSERT_MARKS(100, 308, 1, 202, 14); + EMB_ASSERT_EQ(take(EMB_NO_WAIT), 40u); + EMB_ASSERT_OK(emb_msgq_destroy(mq)); +} + +EMB_TEST(msgq_destroy_aborts_receivers_and_senders) +{ + static emb_msgq_storage_t other_storage; + static EMB_MSGQ_RING(other_ring, sizeof(uint16_t), 1u); + emb_msgq_t full_q; + emb_msgq_attr_t attr; + uint16_t v = 1u; + EMB_TEST_REQ("KRN-OBJ-001", "KRN-WAIT-018"); + make(CAP, EMB_OBJ_ABORT_WAITERS); + (void)emb_test_thread(EMB_TEST_NAME("r3"), 3u, 0u, receiver, ARG(0u, 3u)); + EMB_ASSERT_OK(emb_msgq_destroy(mq)); + EMB_ASSERT_MARKS(703); + emb_msgq_attr_default(&attr); + attr.ring = other_ring; + attr.ring_size = sizeof(other_ring); + attr.item_size = sizeof(uint16_t); + attr.capacity = 1u; + attr.flags = EMB_OBJ_ABORT_WAITERS; + EMB_ASSERT_OK(emb_msgq_init(&other_storage, &attr, &full_q)); + EMB_ASSERT_OK(emb_msgq_send(full_q, &v, EMB_NO_WAIT)); + mq = full_q; + (void)emb_test_thread(EMB_TEST_NAME("s4"), 4u, 0u, sender, ARG(0u, 4u)); + EMB_ASSERT_OK(emb_msgq_destroy(full_q)); /* the stored item is dropped */ + EMB_ASSERT_MARKS(703, 704); +} + +EMB_TEST(msgq_destroy_with_waiters_is_misuse) +{ + EMB_TEST_REQ("KRN-OBJ-001", "API-013"); + make(CAP, 0u); + (void)emb_test_thread(EMB_TEST_NAME("r3"), 3u, 0u, receiver, ARG(0u, 3u)); + EMB_ASSERT_MISUSE(emb_msgq_destroy(mq), EMB_EBUSY); + put(9u); + EMB_ASSERT_MARKS(309); + EMB_ASSERT_OK(emb_msgq_destroy(mq)); +} + +EMB_TEST(msgq_init_validates_the_ring) +{ + emb_msgq_attr_t attr; + emb_msgq_t q; + EMB_TEST_REQ("KRN-IPC-011", "KRN-IPC-019", "API-015"); + emb_msgq_attr_default(&attr); + EMB_ASSERT_MISUSE(emb_msgq_init(&storage, &attr, &q), EMB_EINVAL); /* no ring */ + attr.ring = ring; + attr.ring_size = sizeof(ring); + attr.item_size = sizeof(uint16_t); + attr.capacity = CAP + 1u; + EMB_ASSERT_MISUSE(emb_msgq_init(&storage, &attr, &q), EMB_EINVAL); /* undersized */ + attr.capacity = 0u; + EMB_ASSERT_MISUSE(emb_msgq_init(&storage, &attr, &q), EMB_EINVAL); + attr.capacity = 1u; + attr.item_size = (uint16_t)(CONFIG_EMB_MSGQ_MAX_ITEM + 1); + attr.ring_size = (size_t)-1; + EMB_ASSERT_MISUSE(emb_msgq_init(&storage, &attr, &q), EMB_EINVAL); /* item too large */ + attr.item_size = 1u; + attr.ring = ring + 1; /* misaligned */ + EMB_ASSERT_MISUSE(emb_msgq_init(&storage, &attr, &q), EMB_EINVAL); + EMB_ASSERT_MISUSE(emb_msgq_init(&storage, NULL, &q), EMB_EINVAL); +} + +#if CONFIG_EMB_NOTIFY +EMB_TEST(msgq_binding_fires_on_empty_to_nonempty_only) +{ + emb_notify_bits_t got = 0u; + EMB_TEST_REQ("KRN-IPC-018", "KRN-NOTIF-004", "KRN-NOTIF-013"); + make(CAP, 0u); + EMB_ASSERT_OK(emb_msgq_bind_notify(mq, emb_thread_self(), EMB_NOTIFY_BIT(0))); + EMB_ASSERT_STATUS(emb_msgq_bind_notify(mq, emb_thread_self(), EMB_NOTIFY_BIT(1)), EMB_EEXIST); + (void)emb_test_thread(EMB_TEST_NAME("r3"), 3u, 0u, receiver, ARG(0u, 3u)); + put(1u); /* handed to the receiver: no signal */ + EMB_ASSERT_MARKS(301); + EMB_ASSERT_STATUS(emb_notify_wait(EMB_NOTIFY_BIT(0), EMB_NOTIFY_ANY, EMB_NO_WAIT, &got), + EMB_ETIMEDOUT); + put(2u); /* empty to nonempty: signaled */ + put(3u); /* already nonempty: not again */ + EMB_ASSERT_OK( + emb_notify_wait(EMB_NOTIFY_BIT(0), EMB_NOTIFY_ANY | EMB_NOTIFY_CLEAR, EMB_NO_WAIT, &got)); + EMB_ASSERT_STATUS(emb_notify_wait(EMB_NOTIFY_BIT(0), EMB_NOTIFY_ANY, EMB_NO_WAIT, &got), + EMB_ETIMEDOUT); + EMB_ASSERT_OK(emb_msgq_unbind_notify(mq)); + EMB_ASSERT_STATUS(emb_msgq_unbind_notify(mq), EMB_ENOENT); + EMB_ASSERT_OK(emb_msgq_destroy(mq)); +} +#endif + +#if CONFIG_EMB_OBJ_LIST +EMB_TEST(msgq_storage_reuse_requires_destroy) +{ + emb_msgq_attr_t attr; + emb_msgq_t other; + EMB_TEST_REQ("KRN-OBJ-007", "KRN-OBJ-011"); + make(CAP, 0u); + emb_msgq_attr_default(&attr); + attr.ring = ring; + attr.ring_size = sizeof(ring); + attr.item_size = sizeof(uint16_t); + attr.capacity = CAP; + EMB_ASSERT_MISUSE(emb_msgq_init(&storage, &attr, &other), EMB_EBUSY); + EMB_ASSERT_OK(emb_msgq_destroy(mq)); +} +#endif diff --git a/tests/differential/diff_runner.c b/tests/differential/diff_runner.c index a64fcf2..8fd5cd4 100644 --- a/tests/differential/diff_runner.c +++ b/tests/differential/diff_runner.c @@ -14,13 +14,16 @@ * M=,, a mutex (protocol 0 inherit, 1 ceiling, 2 none) * E=[,fifo][,bind=.] event flags * C= a condition variable (fifo 0 or 1) - * I:;... an ISR body (give, resume, nset, start, eset, eclear) + * Q=[,fifo][,bind=.] a message queue of 32-bit items + * I:;... an ISR body (give, resume, nset, start, eset, eclear, + * and qsend, qfront, qrecv, qpeek with no wait) * Operations (t: -1 forever, 0 no wait, n ticks): * take s t | give s | lock m t | unlock m | nset tid bits | nwait mask all clear t | * sleep t | yield | setprio tid prio | suspend tid | resume tid | cancel tid | destroy s | * slock | sunlock | exit code | start tid | join tid t | bind s tid bit | unbind s | * tdestroy tid | eset e bits | eclear e bits | ewait e mask all clear t | edestroy e | - * cwait c m t | csignal c | cbcast c | cdestroy c + * cwait c m t | csignal c | cbcast c | cdestroy c | qsend q value t | qfront q value | + * qrecv q t | qpeek q | qdestroy q * A schedule entry fires right after the n-th trace event (or in idle, if that event never * comes): "isr@n" raises ISR i, "tick@n" advances virtual time by one tick. */ @@ -36,6 +39,9 @@ #if !CONFIG_EMB_TICKLESS #include #endif +#if CONFIG_EMB_MSGQ +#include /* the final ring contents */ +#endif #define MAX_THREADS 8u #define MAX_OPS 16u @@ -43,6 +49,8 @@ #define MAX_MUTEXES 4u #define MAX_EVFLAGS 4u #define MAX_CONDS 4u +#define MAX_MSGQS 4u +#define MAX_MSGQ_CAP 8u #define MAX_ISRS 4u #define MAX_EVENTS 16384u #define MAX_SCHED 64u @@ -83,7 +91,12 @@ enum op_kind { OP_CWAIT, OP_CSIGNAL, OP_CBCAST, - OP_CDESTROY + OP_CDESTROY, + OP_QSEND, + OP_QFRONT, + OP_QRECV, + OP_QPEEK, + OP_QDESTROY }; struct op { @@ -143,6 +156,20 @@ struct cond_desc { #endif }; +struct msgq_desc { + long qid; + long capacity; + bool fifo; + long bind_tid; + long bind_bit; + bool destroyed; +#if CONFIG_EMB_MSGQ + emb_msgq_storage_t storage; + emb_msgq_t handle; + EMB_MSGQ_RING(ring, sizeof(uint32_t), MAX_MSGQ_CAP); +#endif +}; + struct mutex_desc { long mid; long protocol; @@ -175,6 +202,8 @@ static struct evflag_desc evflags[MAX_EVFLAGS]; static unsigned evflag_count; static struct cond_desc conds[MAX_CONDS]; static unsigned cond_count; +static struct msgq_desc msgqs[MAX_MSGQS]; +static unsigned msgq_count; static struct mutex_desc mutexes[MAX_MUTEXES]; static unsigned mutex_count; static struct program isrs[MAX_ISRS]; @@ -341,6 +370,22 @@ static void parse_op(char *text, struct op *op) strcmp(name, "cdestroy") == 0) { op->kind = (name[1] == 's') ? OP_CSIGNAL : (name[1] == 'b') ? OP_CBCAST : OP_CDESTROY; op->a = num(args[0]); + } else if (strcmp(name, "qsend") == 0) { + op->kind = OP_QSEND; + op->a = num(args[0]); + op->b = num(args[1]); + op->c = num(args[2]); + } else if (strcmp(name, "qfront") == 0) { + op->kind = OP_QFRONT; + op->a = num(args[0]); + op->b = num(args[1]); + } else if (strcmp(name, "qrecv") == 0) { + op->kind = OP_QRECV; + op->a = num(args[0]); + op->b = num(args[1]); + } else if (strcmp(name, "qpeek") == 0 || strcmp(name, "qdestroy") == 0) { + op->kind = (name[1] == 'p') ? OP_QPEEK : OP_QDESTROY; + op->a = num(args[0]); } else { die("unknown operation"); } @@ -431,6 +476,32 @@ static void parse_section(char *sec) c = &conds[cond_count++]; c->cid = num(cut(&p, '=')); c->fifo = num(p) != 0; + } else if (kind == 'Q') { + struct msgq_desc *q; + char *attrs; + if (msgq_count >= MAX_MSGQS) { + die("too many message queues"); + } + q = &msgqs[msgq_count++]; + q->qid = num(cut(&p, '=')); + attrs = p; + q->capacity = num(cut(&attrs, ',')); + if (q->capacity < 1 || q->capacity > (long)MAX_MSGQ_CAP) { + die("bad message queue capacity"); + } + q->bind_tid = -1; + while (attrs != NULL && *attrs != '\0') { + char *a = cut(&attrs, ','); + if (strcmp(a, "fifo") == 0) { + q->fifo = true; + } else if (strncmp(a, "bind=", 5) == 0) { + char *b = a + 5; + q->bind_tid = num(cut(&b, '.')); + q->bind_bit = num(b); + } else { + die("bad message queue attribute"); + } + } } else if (kind == 'M') { struct mutex_desc *m; char *attrs; @@ -541,6 +612,20 @@ static emb_cond_t cond_by_cid(long cid) } #endif +#if CONFIG_EMB_MSGQ +static struct msgq_desc *msgq_by_qid(long qid) +{ + unsigned i; + for (i = 0u; i < msgq_count; i++) { + if (msgqs[i].qid == qid) { + return &msgqs[i]; + } + } + die("unknown message queue id"); + return &msgqs[0]; +} +#endif + static emb_mutex_t mutex_by_mid(long mid) { unsigned i; @@ -781,6 +866,43 @@ static void run_ops(const struct program *prog, struct thread_desc *self) case OP_CDESTROY: die("condition variables are not configured"); break; +#endif +#if CONFIG_EMB_MSGQ + case OP_QSEND: { + uint32_t v = (uint32_t)op->b; + st = emb_msgq_send(msgq_by_qid(op->a)->handle, &v, timeout_of(op->c)); + break; + } + case OP_QFRONT: { + uint32_t v = (uint32_t)op->b; + st = emb_msgq_send_front(msgq_by_qid(op->a)->handle, &v); + break; + } + case OP_QRECV: + case OP_QPEEK: { + uint32_t v = 0u; + st = (op->kind == OP_QRECV) + ? emb_msgq_receive(msgq_by_qid(op->a)->handle, &v, timeout_of(op->b)) + : emb_msgq_peek(msgq_by_qid(op->a)->handle, &v); + data = (st == EMB_OK) ? v : 0u; + break; + } + case OP_QDESTROY: { + struct msgq_desc *q = msgq_by_qid(op->a); + st = emb_msgq_destroy(q->handle); + if (st == EMB_OK) { + q->destroyed = true; + } + break; + } +#else + case OP_QSEND: + case OP_QFRONT: + case OP_QRECV: + case OP_QPEEK: + case OP_QDESTROY: + die("message queues are not configured"); + break; #endif default: die("bad operation kind"); @@ -817,7 +939,7 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) } dumped = 1u; (void)printf("config profile=%s owner_death=%s checked=%d tickless=%d idle_index=254 events=%d " - "conds=%d\n", + "conds=%d msgqs=%d\n", CONFIG_EMB_PROFILE, #if CONFIG_EMB_MUTEX_OWNER_DEATH_FAULT && CONFIG_EMB_CHECKED "fault", @@ -825,7 +947,7 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) "release", #endif (int)CONFIG_EMB_CHECKED, (int)CONFIG_EMB_TICKLESS, (int)CONFIG_EMB_EVENT, - (int)CONFIG_EMB_COND); + (int)CONFIG_EMB_COND, (int)CONFIG_EMB_MSGQ); n = event_count; for (i = 0u; i < n; i++) { (void)printf("ev %u %u %lu %lu %lu %lu\n", (unsigned)events[i].id, @@ -857,6 +979,23 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) (void)printf("evflag %ld %lu\n", evflags[i].eid, (unsigned long)emb_event_get(evflags[i].handle)); } +#endif +#if CONFIG_EMB_MSGQ + for (i = 0u; i < msgq_count; i++) { + const embk_msgq_t *q = (const embk_msgq_t *)(const void *)&msgqs[i].storage; + unsigned k; + if (msgqs[i].destroyed) { + continue; + } + (void)printf("msgq %ld", msgqs[i].qid); + for (k = 0u; k < q->count; k++) { + uint32_t v; + unsigned slot = (q->head + k) % q->capacity; + (void)memcpy(&v, q->ring + ((size_t)slot * q->item_size), sizeof(v)); + (void)printf(" %lu", (unsigned long)v); + } + (void)printf("\n"); + } #endif (void)printf("end %s %lu %lu\n", end, e0, e1); (void)fflush(stdout); @@ -941,6 +1080,22 @@ static void construct(void) if (cond_count != 0u) { die("condition variables are not configured"); } +#endif +#if CONFIG_EMB_MSGQ + for (i = 0u; i < msgq_count; i++) { + emb_msgq_attr_t attr; + emb_msgq_attr_default(&attr); + attr.ring = msgqs[i].ring; + attr.ring_size = sizeof(msgqs[i].ring); + attr.item_size = sizeof(uint32_t); + attr.capacity = (uint16_t)msgqs[i].capacity; + attr.flags = (uint8_t)(EMB_OBJ_ABORT_WAITERS | (msgqs[i].fifo ? EMB_OBJ_FIFO : 0u)); + EMB_CHECK(emb_msgq_init(&msgqs[i].storage, &attr, &msgqs[i].handle)); + } +#else + if (msgq_count != 0u) { + die("message queues are not configured"); + } #endif for (i = 0u; i < mutex_count; i++) { emb_mutex_attr_t attr; @@ -974,6 +1129,15 @@ static void construct(void) EMB_NOTIFY_BIT(evflags[i].bind_bit))); } } +#endif +#if CONFIG_EMB_MSGQ + for (i = 0u; i < msgq_count; i++) { + if (msgqs[i].bind_tid >= 0) { + EMB_CHECK(emb_msgq_bind_notify(msgqs[i].handle, + thread_by_tid(msgqs[i].bind_tid)->handle, + EMB_NOTIFY_BIT(msgqs[i].bind_bit))); + } + } #endif for (i = 0u; i < isr_count; i++) { EMB_CHECK(emb_irq_connect((emb_irq_t)(ISR_IRQ_BASE + i), isr_body, (void *)(uintptr_t)i)); diff --git a/tools/fault/api-ids.json b/tools/fault/api-ids.json index fdbb159..9a93021 100644 --- a/tools/fault/api-ids.json +++ b/tools/fault/api-ids.json @@ -607,6 +607,84 @@ "name": "emb_cond_broadcast", "header": "cond.h", "state": "active" + }, + { + "id": 102, + "name": "emb_msgq_attr_default", + "header": "msgq.h", + "state": "active" + }, + { + "id": 103, + "name": "emb_msgq_init", + "header": "msgq.h", + "state": "active" + }, + { + "id": 104, + "name": "emb_msgq_destroy", + "header": "msgq.h", + "state": "active" + }, + { + "id": 105, + "name": "emb_msgq_send", + "header": "msgq.h", + "state": "active" + }, + { + "id": 106, + "name": "emb_msgq_send_until", + "header": "msgq.h", + "state": "active" + }, + { + "id": 107, + "name": "emb_msgq_send_front", + "header": "msgq.h", + "state": "active" + }, + { + "id": 108, + "name": "emb_msgq_receive", + "header": "msgq.h", + "state": "active" + }, + { + "id": 109, + "name": "emb_msgq_receive_until", + "header": "msgq.h", + "state": "active" + }, + { + "id": 110, + "name": "emb_msgq_peek", + "header": "msgq.h", + "state": "active" + }, + { + "id": 111, + "name": "emb_msgq_count", + "header": "msgq.h", + "state": "active" + }, + { + "id": 112, + "name": "emb_msgq_free", + "header": "msgq.h", + "state": "active" + }, + { + "id": 113, + "name": "emb_msgq_bind_notify", + "header": "msgq.h", + "state": "active" + }, + { + "id": 114, + "name": "emb_msgq_unbind_notify", + "header": "msgq.h", + "state": "active" } ] } diff --git a/tools/fault/api-registry.json b/tools/fault/api-registry.json index 9547825..fdeb0a7 100644 --- a/tools/fault/api-registry.json +++ b/tools/fault/api-registry.json @@ -606,6 +606,84 @@ "name": "emb_cond_broadcast", "header": "cond.h", "state": "active" + }, + { + "id": 102, + "name": "emb_msgq_attr_default", + "header": "msgq.h", + "state": "active" + }, + { + "id": 103, + "name": "emb_msgq_init", + "header": "msgq.h", + "state": "active" + }, + { + "id": 104, + "name": "emb_msgq_destroy", + "header": "msgq.h", + "state": "active" + }, + { + "id": 105, + "name": "emb_msgq_send", + "header": "msgq.h", + "state": "active" + }, + { + "id": 106, + "name": "emb_msgq_send_until", + "header": "msgq.h", + "state": "active" + }, + { + "id": 107, + "name": "emb_msgq_send_front", + "header": "msgq.h", + "state": "active" + }, + { + "id": 108, + "name": "emb_msgq_receive", + "header": "msgq.h", + "state": "active" + }, + { + "id": 109, + "name": "emb_msgq_receive_until", + "header": "msgq.h", + "state": "active" + }, + { + "id": 110, + "name": "emb_msgq_peek", + "header": "msgq.h", + "state": "active" + }, + { + "id": 111, + "name": "emb_msgq_count", + "header": "msgq.h", + "state": "active" + }, + { + "id": 112, + "name": "emb_msgq_free", + "header": "msgq.h", + "state": "active" + }, + { + "id": 113, + "name": "emb_msgq_bind_notify", + "header": "msgq.h", + "state": "active" + }, + { + "id": 114, + "name": "emb_msgq_unbind_notify", + "header": "msgq.h", + "state": "active" } ] } diff --git a/tools/model/bridge.py b/tools/model/bridge.py index a264056..1c60a0f 100644 --- a/tools/model/bridge.py +++ b/tools/model/bridge.py @@ -45,6 +45,8 @@ TR_NOTIFY_SET, TR_NOTIFY_WAIT, TR_FAULT, TR_TICK = 20, 21, 25, 29 TR_EVENT_SET, TR_EVENT_WAIT = 31, 32 TR_COND_WAIT, TR_COND_SIGNAL = 33, 34 +TR_MSGQ_SEND, TR_MSGQ_RECEIVE = 35, 36 +CTX_THREAD = 0 # emb_context_t EV_ISR_MARK = 1000 # the runner's own record: ISR body i starts EV_INDEX_MARK = 1001 # the runner's record: thread tid now has index i IDLE_INDEX = 0xFE @@ -218,6 +220,7 @@ def __init__(self): self.states = {} # tid -> (state, reason) self.sems = {} # sid -> count self.evflags = {} # eid -> bits + self.msgqs = {} # qid -> stored items, head first self.end = None # ("done",) or ("fault", class, code) self.events_lost = 0 self.exit_code = 0 @@ -283,6 +286,8 @@ def parse_run(stdout: str, stderr: str = "", exit_code: int = 0) -> Run: r.sems[int(f[1])] = int(f[2]) elif f[0] == "evflag": r.evflags[int(f[1])] = int(f[2]) + elif f[0] == "msgq": + r.msgqs[int(f[1])] = tuple(int(x) for x in f[2:]) elif f[0] == "events_lost": r.events_lost = int(f[1]) elif f[0] == "end": @@ -316,14 +321,15 @@ def thr(idx): raise Divergence(f"kernel event names an unknown thread index {idx}") return tid_of[idx] - for (eid, _ctx, ticks, a0, a1, a2) in run.events: + for (eid, ctx, ticks, a0, a1, a2) in run.events: if eid == TR_SWITCH: out.append(("switch", thr(a1))) elif eid == TR_BLOCK: out.append(("wait_begin", thr(a0), KREASON_TO_REASON[a1], ("q", a2))) elif eid == TR_WAKE: out.append(("wake", thr(a0), KWAKE_TO_RESULT[a1])) - elif eid in (TR_SEM_TAKE, TR_NOTIFY_WAIT, TR_MUTEX_LOCK, TR_EVENT_WAIT, TR_COND_WAIT): + elif eid in (TR_SEM_TAKE, TR_NOTIFY_WAIT, TR_MUTEX_LOCK, TR_EVENT_WAIT, TR_COND_WAIT) or \ + (eid in (TR_MSGQ_SEND, TR_MSGQ_RECEIVE) and ctx == CTX_THREAD): # an ISR's call has no wait st = a1 if eid == TR_NOTIFY_WAIT else a2 st = st - (1 << 64) if st >= (1 << 63) else st # printed as unsigned long res = Result.SATISFIED if st == -7 else STATUS_TO_RESULT.get(st) @@ -594,7 +600,8 @@ def finish(self, k, path): kres_mapped = Result.TIMEOUT # a completed sleep is EMB_OK in the kernel if kres_mapped != result: raise Divergence(f"thread {tid} op {op_index} {op!r}: model {result.name}, kernel status {kstatus}") - if isinstance(op, (NotifyWait, Join, Lock, EventWait, CondWait)) and result == Result.SATISFIED \ + if isinstance(op, (NotifyWait, Join, Lock, EventWait, CondWait, MsgRecv, MsgPeek)) \ + and result == Result.SATISFIED \ and kdata != data: raise Divergence(f"thread {tid} op {op_index} {op!r}: model data {data}, kernel data {kdata}") kstate = r.states.get(tid) @@ -616,6 +623,9 @@ def finish(self, k, path): for eid, e in k.events.items(): if eid in r.evflags and not e.destroyed and r.evflags[eid] != e.bits: raise Divergence(f"event flags {eid}: kernel bits {r.evflags[eid]:#x}, model {e.bits:#x}") + for qid, q in k.msgqs.items(): + if not q.destroyed and r.msgqs.get(qid) != q.ring: + raise Divergence(f"message queue {qid}: kernel items {r.msgqs.get(qid)}, model {q.ring}") if k.enabled() and any(a[0] == "run" for a in k.enabled()): raise Divergence("the kernel finished while the model still has a thread to run") @@ -678,7 +688,8 @@ def applicable(sc: Scenario, config: dict) -> tuple: if len(set(prios)) != len(prios): return False, "shared priorities need the base profile" if any(cfg.get("policy") == Policy.FIFO - for cfg in list(sc.sems.values()) + list(sc.events.values()) + list(sc.conds.values())): + for cfg in list(sc.sems.values()) + list(sc.events.values()) + list(sc.conds.values()) + + list(sc.msgqs.values())): return False, "FIFO queues need the base profile" elif bitmap: return False, "BITMAP queues need the tiny profile" @@ -691,7 +702,8 @@ def adapt(sc: Scenario, config: dict) -> Scenario: sems = {sid: dict(cfg, policy=Policy.BITMAP) for sid, cfg in sc.sems.items()} events = {eid: dict(cfg, policy=Policy.BITMAP) for eid, cfg in sc.events.items()} conds = {cid: dict(cfg, policy=Policy.BITMAP) for cid, cfg in sc.conds.items()} - sc = replace(sc, sems=sems, events=events, conds=conds) + msgqs = {qid: dict(cfg, policy=Policy.BITMAP) for qid, cfg in sc.msgqs.items()} + sc = replace(sc, sems=sems, events=events, conds=conds, msgqs=msgqs) # Historical native captures predate the clock-mode field and used tickless builds. return replace(sc, owner_death=config.get("owner_death", "release"), tickless=config.get("tickless", "1") == "1") diff --git a/tools/model/embmodel/kernel.py b/tools/model/embmodel/kernel.py index e499f0c..8588331 100644 --- a/tools/model/embmodel/kernel.py +++ b/tools/model/embmodel/kernel.py @@ -1437,15 +1437,18 @@ def step_thread(self) -> bool: self._op_event_wait(t, op) elif isinstance(op, CondWait): self._op_cond_wait(t, op) - elif isinstance(op, MsgSend): - self._op_msg_send(t, op) - elif isinstance(op, MsgRecv): - self._op_msg_recv(t, op) - elif isinstance(op, NONWAIT_DATA_OPS): + elif isinstance(op, (MsgSend, MsgRecv)): + done = self._op_msg_send(t, op) if isinstance(op, MsgSend) else self._op_msg_recv(t, op) + if done: + self.reschedule_if_needed() # a hand-off may preempt + elif isinstance(op, MsgSendFront): res, data = self._op_msg_nowait(op) - t.results = t.results + ((t.op_index, res, data),) - self._advance(t) + self._finish(t, res, data) # traced like a send self.reschedule_if_needed() + elif isinstance(op, MsgPeek): + res, data = self._op_msg_nowait(op) + t.results = t.results + ((t.op_index, res, data),) # a snapshot: not traced + self._advance(t) elif isinstance(op, MsgDestroy): self._op_msg_destroy(op) self._advance(t) diff --git a/tools/model/tests/scenarios.py b/tools/model/tests/scenarios.py index 314225b..f23caf7 100644 --- a/tools/model/tests/scenarios.py +++ b/tools/model/tests/scenarios.py @@ -591,7 +591,16 @@ def msgq_binding_on_empty_to_nonempty(): msgqs={0: dict(capacity=2, binding=(1, 0))}) +def msgq_refill_does_not_signal_binding(): + """A receive that refills the freed slot from a waiting sender never leaves the queue empty: + the bound thread is not notified again.""" + return Scenario(name="msgq_refill_does_not_signal_binding", + threads={1: (2, (MsgSend(0, 1), MsgSend(0, 2))), + 2: (1, (NotifyWait(0x1, clear=True), MsgRecv(0), NotifyWait(0x1, timeout=NO_WAIT)))}, + msgqs={0: dict(capacity=1, binding=(2, 0))}) + + MSGQ = [msgq_fifo_order, msgq_receiver_handoff, msgq_send_front_and_peek, msgq_isr_send_vs_timed_recv, msgq_isr_recv_frees_a_slot, msgq_sender_timeout_and_cancel, msgq_destroy_aborts_both_sides, - msgq_binding_on_empty_to_nonempty] + msgq_binding_on_empty_to_nonempty, msgq_refill_does_not_signal_binding] ALL = ALL + MSGQ diff --git a/tools/model/tests/test_explore.py b/tools/model/tests/test_explore.py index b8f7d50..b724606 100644 --- a/tools/model/tests/test_explore.py +++ b/tools/model/tests/test_explore.py @@ -437,3 +437,9 @@ def test_binding_fires_on_the_ready_transition_only(self): self.assertEqual(d[1][0][2], 1) # storing 2 signaled bit 0 self.assertEqual(q[0], (2, 3)) self.assertEqual(seen, {Result.SATISFIED, Result.TIMEOUT}) + + def test_refill_from_a_waiting_sender_does_not_signal(self): + for d, q in self.outcomes(S.msgq_refill_does_not_signal_binding): + self.assertEqual([(r[1], r[2]) for r in d[2]], + [(Result.SATISFIED, 1), (Result.SATISFIED, 1), (Result.TIMEOUT, 0)]) + self.assertEqual(q[0], (2,)) diff --git a/tools/toolchain/native_abi.c b/tools/toolchain/native_abi.c index 7e82892..89bb9e8 100644 --- a/tools/toolchain/native_abi.c +++ b/tools/toolchain/native_abi.c @@ -138,6 +138,14 @@ void emb_native_abi_probe(void) FIELD(cond_obj, embk_cond_t, obj); FIELD(cond_waiters, embk_cond_t, waiters); #endif +#if CONFIG_EMB_MSGQ + TYPE(msgq, embk_msgq_t); + FIELD(msgq_obj, embk_msgq_t, obj); + FIELD(msgq_receivers, embk_msgq_t, receivers); + FIELD(msgq_senders, embk_msgq_t, senders); + FIELD(msgq_ring, embk_msgq_t, ring); + FIELD(msgq_count, embk_msgq_t, count); +#endif #if CONFIG_EMB_MUTEX TYPE(mutex, embk_mutex_t); FIELD(mutex_obj, embk_mutex_t, obj); From 7f6b3aad1a9ebaded995e336d42641d75d2e3a8c Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 20:02:00 +0100 Subject: [PATCH 17/30] fix(toolchain): include the IPC objects in the native ABI probe (M2-07) The probe's message queue entries name embk_msgq_t, which only declares; the EmbCC native comparison would not compile. CI does not build that flow, so nothing caught it. Signed-off-by: teo1747 --- tools/toolchain/native_abi.c | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/toolchain/native_abi.c b/tools/toolchain/native_abi.c index 89bb9e8..3281c4e 100644 --- a/tools/toolchain/native_abi.c +++ b/tools/toolchain/native_abi.c @@ -5,6 +5,7 @@ * by the existing storage generator. Member widths are compared separately. * This is a maintained layout inventory, supplemented by executed ABI crossings. */ +#include #include #include From 11ff856f4d5c93ccb97702b2018571402252dac3 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 20:12:35 +0100 Subject: [PATCH 18/30] feat(work): work queues on kernel threads (M2-08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work queues (SPEC-006 §4, KRN-WQ) under CONFIG_EMB_WORKQ, in include/emb/work.h and kernel/work/, for the list scheduler. - A queue embeds its thread and constructs it through the application thread's checks, flagged EMBK_THREAD_KERNEL (ADR-048, A51): thread calls on its handle are refused, notifications to it are OWNER misuse, and emb_thread_exit() from a handler is a context fault. thread.c gains the shared construct, start, exit and release steps; T1 text shrinks by 2 bytes. - The thread drains the FIFO one item at a time, marked RUNNING while the handler runs outside the critical section, and waits on EMB_NOTIFY_K_WORK when it is empty. Submit is O(1) and ISR-safe: EEXIST while queued, kept for one more run while running on the same queue, EBUSY on another. - cancel reports PENDING, RUNNING or IDLE; cancel_sync and flush wait on a completion queue for the item or a stack marker. Both are misuse from the queue's own thread, and both fix their deadline at the call. - Destroy refuses a busy queue, stops the thread and waits, not cancelably, for it to exit; a racing submit or flush is ESTATE (A49). - emb_work_t wraps generated opaque storage (A48). - The system queue sits in its own object file with an init-table entry, so images link it only when they reference emb_workq_system() (A50). API IDs 115-124, trace IDs 37-40, type tag 0x57. The reference model gains work queues (A53): a queue thread whose program follows its state, the drain loop or a handler body of model operations. Invariants tie each item's state to the FIFOs and running items and every completion waiter to something that can complete. The WORKQ group covers FIFO order and idempotent submit, resubmission, cancel states, flush with a timeout and while a handler runs, ISR submit and destroy. The runner and bridge carry W, H and the work operations, and the bridge follows the queue thread's own trace. A seeded campaign found that flush computed its deadline after a tick could advance the clock inside its first section; the input is now a timer regression, and check_evidence counts the applicable regressions rather than four. tests/conformance/test_workq.c has 10 cases. The ABI probe now includes . Signed-off-by: teo1747 --- CHANGELOG.md | 2 + docs/STATUS.md | 5 +- docs/architecture/06-decision-records.md | 11 + docs/engineering/m2-checklist.md | 2 +- docs/engineering/resource-bounds.json | 25 +- docs/engineering/resource-bounds.md | 7 +- docs/requirements/KRN-WQ.md | 36 +- docs/specs/AMENDMENTS-M2.md | 6 + .../SPEC-006-notifications-and-work-queues.md | 11 +- docs/user/feature-status.md | 2 +- docs/user/tasks.md | 35 ++ include/emb/api_ids.h | 10 + include/emb/emb.h | 3 + include/emb/trace.h | 21 +- include/emb/work.h | 260 +++++++++++ kernel/CMakeLists.txt | 6 + kernel/Kconfig | 29 ++ kernel/include/embk/kernel.h | 1 + kernel/include/embk/thread.h | 13 + kernel/include/embk/work.h | 44 ++ kernel/notify/notify.c | 5 + kernel/object/storage_probe.c | 5 + kernel/thread/thread.c | 165 ++++--- kernel/work/system_workq.c | 45 ++ kernel/work/workq.c | 430 ++++++++++++++++++ tests/conformance/CMakeLists.txt | 3 + tests/conformance/test_workq.c | 229 ++++++++++ tests/differential/diff_runner.c | 164 ++++++- tools/fault/api-ids.json | 60 +++ tools/fault/api-registry.json | 60 +++ tools/model/README.md | 4 +- tools/model/bridge.py | 38 +- tools/model/check_evidence.py | 9 +- tools/model/embmodel/__init__.py | 1 + tools/model/embmodel/kernel.py | 339 +++++++++++++- tools/model/evidence.py | 8 +- tools/model/tests/scenarios.py | 74 +++ tools/model/tests/test_explore.py | 63 +++ tools/toolchain/native_abi.c | 10 + 39 files changed, 2125 insertions(+), 116 deletions(-) create mode 100644 include/emb/work.h create mode 100644 kernel/include/embk/work.h create mode 100644 kernel/work/system_workq.c create mode 100644 kernel/work/workq.c create mode 100644 tests/conformance/test_workq.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 101bd87..32eaa49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Added +- Work queues (`CONFIG_EMB_WORKQ`, `include/emb/work.h`, M2-08): a kernel thread per queue runs submitted items one at a time in FIFO order. Submit is O(1) and ISR-safe, idempotent while queued and kept while running; cancel reports the state, `cancel_sync` and `flush` wait for completion, and destroy joins the queue's thread. The system work queue (`CONFIG_EMB_SYSTEM_WORKQ`) is linked only into images that reference `emb_workq_system()`. The reference model, the differential bridge and a conformance suite cover them; they require the list scheduler, so the tiny profile has none in this milestone. + - Message queues (`CONFIG_EMB_MSGQ`, `include/emb/msgq.h`, M2-07): fixed-size items copied by value through a caller-provided ring declared with `EMB_MSGQ_RING()`, with direct hand-off to a waiting receiver, slot hand-off from a waiting sender, `send_front`, `peek`, `count` and `free`, `EMB_NO_WAIT` calls from interrupts, timeouts and absolute deadlines, cancellation, destroy with ABORT_WAITERS, and a receive-side notification binding. Items are bounded by `CONFIG_EMB_MSGQ_MAX_ITEM` (64 bytes, 16 on tiny, where `CONFIG_EMB_IPC_SMALL` keeps 8-bit counts). The reference model, the differential bridge and a conformance suite cover them; they are off in the tiny `footprint_t1` reference configuration. - The reference model covers copy-by-value message queues ahead of their implementation (KRN-IPC-022, M2-07): send, send_front, receive and peek, ISR calls with `EMB_NO_WAIT`, hand-off to a waiting receiver, refill from a waiting sender, destroy, and the empty-to-nonempty binding, checking in every explored state that each accepted item is received once, still stored or dropped by destroy, and that no side waits while the other could proceed. diff --git a/docs/STATUS.md b/docs/STATUS.md index a9b42a9..dfd4bf2 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,6 +12,7 @@ This page is the authoritative inventory of implementation and available evidenc | Waiting and time | Shared wait protocol, sleep, deadlines, cancellation, suspend overlay, ordered timeouts | Native virtual time exercises periodic and tickless modes; embedded ports currently reject tickless | | Synchronization | Semaphores, mutexes with inheritance, ceiling, recursion and owner-death policy; event flags with ANY/ALL/CLEAR waits and binding (M2-05); condition variables with the mandatory relock (M2-06) | Conformance and native differential bridge; inheritance and deadlock detection have a configured depth limit | | Communication | Copy-by-value message queues: direct hand-off to a waiting receiver, slot hand-off from a waiting sender, send to front, peek, `EMB_NO_WAIT` calls from ISRs and a receive-side binding (M2-07) | Conformance and native differential bridge; no pipes, buffer pools or zero-copy ownership queues; the masked copy time is bounded by configuration, not measured on hardware | +| Deferred work | Work queues on a kernel thread with FIFO execution, idempotent and ISR-safe submit, resubmission while running, cancel and synchronous cancel, flush, destroy joining the thread, and a system queue linked only when referenced (M2-08, ADR-048) | Conformance and native differential bridge; list scheduler only (not tiny); no delayed work, timers or statistics | | Notifications | Per-thread bits and one semaphore-to-thread binding, with unbind; a bound thread cannot be destroyed (ADR-046) | Binding takes a one-bit mask; other objects' bindings arrive with those objects; restart generations need the isolated profile | | Objects | Caller-owned generated storage and POINTER handles; per-type active-object lists for semaphores and mutexes (`CONFIG_EMB_OBJ_LIST`) used by init | No INDEXED or TABLE handles, capability rights, partition restart, system freeze, `emb_obj_foreach()` or debug-descriptor export | | Native port | POSIX host-thread gate, deterministic IRQ injection, virtual time | Base/tiny development workflow; no current wall-clock, asynchronous signal stress, simulated isolated profile or Win32 backend | @@ -20,11 +21,11 @@ This page is the authoritative inventory of implementation and available evidenc | Observability | M1 trace sink and fault reporting | CTF rings/metadata, deferred log decoder, retained crash records, debug descriptor and worst-case monitors remain planned | | Documentation | Sphinx/MyST manuals and native tutorials, Doxygen/Breathe C API, Sphinx Needs design catalog, JSON/CSV downloads and annotation audit | Strict local HTML build, complete extracted function annotations, source-preservation and catalog-integrity checks; merged `37887c9` Pages deployment and served evidence-workflow page independently observed successful | | Repository process | Issue forms, PR checklist, changelog, release/M2 work checklists, stable CI merge checks, authored-commit DCO checker and a main ruleset policy | Local syntax and DCO/gate failure checks; GitHub's effective main rules report PRs, resolved conversations, the three strict CI checks and deletion/non-fast-forward protection; no remote rejection experiment claimed | -| Semantic model | UP scheduler/wait/mutex/notification/lifecycle scenarios; message queues, modeled before their implementation (KRN-IPC-022); exhaustive section-level exploration and randomized walks | Scenario-scoped exploration, not a proof of all C execution, arbitrary programs, SMP or hardware protection | +| Semantic model | UP scheduler/wait/mutex/notification/lifecycle scenarios; message queues, modeled before their implementation (KRN-IPC-022), and work queues; exhaustive section-level exploration and randomized walks | Scenario-scoped exploration, not a proof of all C execution, arbitrary programs, SMP or hardware protection | ## Specified but unimplemented -Barriers, software timers, work queues, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. +Barriers, software timers, delayed work, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. An API or path named in a design or a requirement's verification field may be a planned deliverable. Check this inventory and the filesystem before treating it as available. diff --git a/docs/architecture/06-decision-records.md b/docs/architecture/06-decision-records.md index bedf214..9e440bd 100644 --- a/docs/architecture/06-decision-records.md +++ b/docs/architecture/06-decision-records.md @@ -564,3 +564,14 @@ Each record: context, decision, alternatives, consequences, status. **Proposed** **Status.** Implemented for review, 2026-10-27, as M2-03; awaiting project-owner review. +## ADR-048 Work queue threads are kernel threads; the system queue is linked only when used + +**Context.** SPEC-006 §4 builds a work queue from a thread and an intrusive FIFO, and gives `base` a system queue that exists from kernel start. A queue thread created as an ordinary application thread would be reachable through `emb_thread_self()` in a handler, so a handler could cancel, suspend, reprioritize, notify or destroy the thread it runs on, and could leave the queue without a thread. A system queue that every `base` image constructs costs a control block, a stack (2 KB by default) and a thread start in images that never submit to it, and it adds construction records to every trace, including the archived M1 captures the differential bridge replays. + +**Decision.** A work queue embeds its thread in the queue object and constructs it through the application path's checks, flagged `EMBK_THREAD_KERNEL`: thread calls on its handle return or fault with `EMB_EPERM` (cancel, suspend, priority, join, detach, destroy, binding), `emb_notify_set()` to it is `EMB_FAULT_API_OWNER` misuse, it is never canceled, and `emb_thread_exit()` from a handler is a context fault. Only the queue ends it: destroy refuses a busy queue, sets a stop flag and the `K_WORK` bit, and waits, not cancelably, on the thread's join slot; the thread exits at an empty FIFO and the destroyer releases its identity. The system queue lives in its own object file with an initialization-table entry, so the linker pulls in the queue, its thread and its stack only when `emb_workq_system()` is referenced, and kernel initialization then constructs it. + +**Alternatives.** A separate object type tag for queue threads, like the idle context's (ADR-050), would hide them from `emb_thread_self()` and the thread list as well, but handlers legitimately query their own priority and state, and the kernel-thread flag already guards every handle operation. Letting destroy abandon a thread blocked in its notification wait without joining it would leave its identity and table slot held by a context that is never resumed. Constructing the system queue unconditionally matches SPEC-006's wording but makes every image pay for it; constructing it lazily on first use would put thread creation inside an ISR-safe `submit`. + +**Consequences.** M1 images and traces are unchanged, and the footprint of an image that does not use work queues does not grow. An application that references the system queue from a translation unit also gets its stack; one that only creates its own queues does not. Work queue destroy blocks until the thread has switched out, so it is thread-only. KRN-WQ-008's "exists from kernel start" holds for every image that can name the queue. The table scheduler has no work queues in M2 (A53). + +**Status.** Implemented for review, 2026-10-27, as part of M2-08; awaiting project-owner review. diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index 0d9239d..bdbd48f 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -78,7 +78,7 @@ The `footprint_t1` reference build was at the R-003 T2 limit of 64 bytes of kern - [x] **Event flags:** model overlapping masks and ANY/ALL/CLEAR; test deterministic waiter order, clearing before the next waiter, multi-wake cost, ISR set, timeout/cancel/destroy and binding semantics. Follow SPEC-005 and KRN-SYNC-029. - [x] **Condition variables:** model atomic mutex release/enqueue, no stored signal, and mandatory mutex reacquisition even after timeout/cancel; test broadcasts, priority inheritance, recursive-mutex restrictions and relock response-time implications. Follow SPEC-005 and KRN-SYNC-030. - [x] **Message queues:** start with the copy-by-value subset; validate item-size/capacity arithmetic and aligned caller storage, direct receiver/slot hand-off, front send, no-wait ISR paths, absolute deadlines, cancellation/destruction and readiness recheck. Bound bytes copied while masked. Follow SPEC-007/KRN-IPC; ownership queues depend on a later pool gate and must not be advertised early. -- [ ] **Work queues:** define callback thread/priority and serialized execution; model submit/resubmit states and cancel/flush including callback self-wait misuse, delayed scheduling and teardown. Follow SPEC-006/KRN-WQ; expose only implemented configurations. +- [x] **Work queues:** define callback thread/priority and serialized execution; model submit/resubmit states and cancel/flush including callback self-wait misuse, delayed scheduling and teardown. Follow SPEC-006/KRN-WQ; expose only implemented configurations. - [ ] **Software timers:** use the work queue for default callbacks; model absolute periodic rearm, missed-period policy and checked overflow arithmetic, cancel/destroy while callbacks are queued/running, and any explicitly supported ISR callback mode. Follow SPEC-003/KRN-TIM; do not silently enable embedded tickless before its port contract is implemented. For each new public API: complete annotations and parameter/lifetime docs, generated storage checks, native checked/release/tiny applicability, boundary misuse, model agreement, emulator evidence for supported ports and a finite user example. An unsupported configuration must be rejected or explicitly excluded; it is not verified by a skipped test. diff --git a/docs/engineering/resource-bounds.json b/docs/engineering/resource-bounds.json index ee13cc7..1c25119 100644 --- a/docs/engineering/resource-bounds.json +++ b/docs/engineering/resource-bounds.json @@ -95,7 +95,7 @@ }, { "id": "active_objects", - "input": "active objects of one type (semaphores, mutexes, event flags, condition variables, message queues)", + "input": "active objects of one type (semaphores, mutexes, event flags, condition variables, message queues, work queues)", "bound": "not limited by the kernel: the objects the application initializes", "limit": "application", "enforcement": "application", @@ -347,6 +347,29 @@ ], "emb_msgq_peek": [ "item_copy" + ], + "emb_workq_init": [ + "thread_ids", + "live_threads", + "stack", + "active_objects" + ], + "emb_workq_destroy": [ + "timeouts", + "reschedule" + ], + "emb_workq_flush": [ + "waiters", + "timeouts", + "reschedule" + ], + "emb_work_submit": [ + "timeouts", + "reschedule" + ], + "emb_work_cancel_sync": [ + "waiters", + "timeouts" ] } } diff --git a/docs/engineering/resource-bounds.md b/docs/engineering/resource-bounds.md index 9e26a3d..27b7f41 100644 --- a/docs/engineering/resource-bounds.md +++ b/docs/engineering/resource-bounds.md @@ -15,7 +15,7 @@ Every cost input named by a public function's `@time` annotation, with how it is | **batch_wake**: waiters woken at once (destroy with EMB_OBJ_ABORT_WAITERS) | the waiters of the destroyed object | as for waiters | structural | yes | one section, one reschedule at the end | SPEC-004 §6.7; KRN-OBJ-001 | | **thread_ids**: diagnostic identity scan at thread init | 254 IDs on list profiles, CONFIG_EMB_PRIORITY_COUNT - 1 on tiny | fixed per profile; EMB_ENOMEM beyond it | admission | yes | bitmap scan from the lowest ID | ADR-043; m2-02-thread-identity.md | | **live_threads**: threads on the all-threads list | at most 254 (the ID space) | CONFIG_EMB_THREAD_LIST; the ID admission | admission | yes | base only (no list on tiny); walked by thread init and emb_thread_foreach | SPEC-008 §12; ADR-045 | -| **active_objects**: active objects of one type (semaphores, mutexes, event flags, condition variables, message queues) | not limited by the kernel: the objects the application initializes | application | application | yes | with CONFIG_EMB_OBJ_LIST only (not on tiny); walked by init | ADR-045; m2-02-object-lifecycle.md | +| **active_objects**: active objects of one type (semaphores, mutexes, event flags, condition variables, message queues, work queues) | not limited by the kernel: the objects the application initializes | application | application | yes | with CONFIG_EMB_OBJ_LIST only (not on tiny); walked by init | ADR-045; m2-02-object-lifecycle.md | | **item_copy**: bytes copied for one message queue operation | at most two items (a receive that refills from a waiting sender), each item_size bytes | CONFIG_EMB_MSGQ_MAX_ITEM (default 64, tiny 16); init refuses a larger item_size | configuration | yes | a byte loop under the object lock on every profile | SPEC-007 §2.2, KRN-IPC-011; tests/conformance/test_msgq.c | | **stack**: bytes of one thread's stack | the stack size the application supplied | application | application | no | the high-water scan exists only with CONFIG_EMB_STACK_STATS; init fills the stack in checked and statistics builds | SPEC-008 §10; KRN-MEM-009 | | **init_table**: entries of the initialization table | the EMB_*_DEFINE objects linked into the image | application, fixed at link time | application | no | prekernel only | KRN-OBJ-009 | @@ -70,3 +70,8 @@ Every cost input named by a public function's `@time` annotation, with how it is | `emb_thread_sleep()` | O(timeouts) arm | timeouts | | `emb_thread_sleep_until()` | O(timeouts) arm | timeouts | | `emb_thread_stack_info()` | O(stack) scan with statistics on | stack | +| `emb_work_cancel_sync()` | O(1); O(waiters + timeouts) finite wait enqueue | waiters, timeouts | +| `emb_work_submit()` | O(1), plus the queue thread's wake | timeouts, reschedule | +| `emb_workq_destroy()` | O(1) plus the queue thread's exit | timeouts, reschedule | +| `emb_workq_flush()` | O(1) enqueue of a marker; O(waiters + timeouts) finite wait enqueue | waiters, timeouts, reschedule | +| `emb_workq_init()` | as emb_thread_init(); O(active work queues) list lookup with CONFIG_EMB_OBJ_LIST | thread_ids, live_threads, stack, active_objects | diff --git a/docs/requirements/KRN-WQ.md b/docs/requirements/KRN-WQ.md index 502408c..8241c94 100644 --- a/docs/requirements/KRN-WQ.md +++ b/docs/requirements/KRN-WQ.md @@ -11,15 +11,15 @@ KRN-WQ-001 to 004 originate in `docs/architecture/03-kernel-architecture.md` §6 ### KRN-WQ-001 Intrusive, static, ISR-safe submit **Statement.** Work items shall be intrusive and statically allocatable; submission shall be ISR-safe and O(1). **Rationale.** A bottom half is submitted from interrupts; it must cost one push and one bit set. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `wq/submit_from_isr`; Analysis: no allocation and no list walk in `emb_work_submit`. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-08). +**Verification.** Test: `workq_submit_from_isr_runs_at_exit`, `workq_runs_items_in_fifo_order_on_its_thread`; Analysis: no allocation and no list walk in `emb_work_submit`; model scenario `work_isr_submit`. **Trace.** SPEC-006 §4.3 ### KRN-WQ-002 FIFO per queue in thread context **Statement.** Work executes in FIFO order per queue in thread context. **Rationale.** Deterministic order; handlers may use thread-only API. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `wq/fifo_order`, `wq/handler_may_block`. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-08). +**Verification.** Test: `workq_runs_items_in_fifo_order_on_its_thread`, `workq_cancel_reports_pending_running_and_idle` (a handler that sleeps); model scenario `work_fifo_and_idempotent_submit`; differential agreement on the native presets. **Trace.** SPEC-006 §4.3 ### KRN-WQ-003 Delayed work over software timers @@ -32,22 +32,22 @@ KRN-WQ-001 to 004 originate in `docs/architecture/03-kernel-architecture.md` §6 ### KRN-WQ-004 Cancellation reports the state **Statement.** Cancellation shall report whether the item was pending, running, or idle. **Rationale.** The caller must know whether a handler may still run. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `wq/cancel_pending`, `wq/cancel_running`, `wq/cancel_idle`. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-08). +**Verification.** Test: `workq_cancel_reports_pending_running_and_idle`; model scenario `work_cancel_states`. **Trace.** SPEC-006 §4.3 ### KRN-WQ-005 Submit outcomes **Statement.** Submitting a queued item shall return `EMB_EEXIST` and change nothing; submitting an item running on the same queue shall return `EMB_OK` and re-append it after its handler returns; submitting an item running on another queue shall return `EMB_EBUSY`; an item shall never run concurrently with itself. **Rationale.** Idempotent submission without lost re-submissions and without handler re-entrancy (Zephyr's v2 rule, R-001 §6). -**Status.** Accepted 2026-10-07 -**Verification.** Test: `wq/submit_queued_eexist`, `wq/resubmit_while_running_runs_again`, `wq/submit_other_queue_ebusy`. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-08). +**Verification.** Test: `workq_runs_items_in_fifo_order_on_its_thread` (EEXIST), `workq_resubmit_while_running_runs_again`; `workq_item_belongs_to_one_queue_at_a_time` (EBUSY); model scenarios `work_fifo_and_idempotent_submit`, `work_resubmit_while_running` and the item-state invariants. **Trace.** SPEC-006 §4.3, §12.5 ### KRN-WQ-006 Synchronous cancel and flush **Statement.** `cancel_sync` shall wait until a running handler has returned; `flush` shall return when every item queued before the call has run; calling either from the queue's own thread shall be misuse. **Rationale.** Teardown needs a point after which no handler touches the caller's state. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `wq/cancel_sync_waits`, `wq/flush`, `misuse/wq_flush_from_handler`. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-08). +**Verification.** Test: `workq_cancel_reports_pending_running_and_idle`, `workq_flush_waits_for_items_queued_before_it`; model scenarios `work_flush_and_timeout`, `work_flush_waits_for_running_handler`; the `differential_timer_regressions` flush input. Misuse from the queue's own thread is checked by the model and by inspection. **Trace.** SPEC-006 §4.3, §6 ### KRN-WQ-007 Delayed work scheduling @@ -60,27 +60,27 @@ KRN-WQ-001 to 004 originate in `docs/architecture/03-kernel-architecture.md` §6 ### KRN-WQ-008 System work queue **Statement.** With `CONFIG_EMB_SYSTEM_WORKQ` the system work queue shall exist from kernel start at `CONFIG_EMB_SYSTEM_WORKQ_PRIO` with `CONFIG_EMB_SYSTEM_WORKQ_STACK`; it shall be the default queue of software timers; the tiny profile shall default to no system queue. **Rationale.** Timer callbacks need a thread; the tiny profile cannot afford one by default. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `wq/system_queue_exists`; footprint test of the tiny profile without it. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-08); the queue is linked only when referenced (ADR-048, A50). +**Verification.** Test: `workq_system_queue_runs_items`; footprint of the M1 images, which do not reference it, is unchanged. **Trace.** SPEC-006 §4.3, §8; SPEC-003 §7.3; ADR-019 ### KRN-WQ-009 Work queue thread **Statement.** A work queue's thread shall wait on the `K_WORK` notification bit, drain the FIFO one item at a time with the item marked `RUNNING` outside the critical section, and run handlers at the queue's priority. **Rationale.** Fixes the implementation shape so that latency is the thread's dispatch latency plus the handlers ahead. -**Status.** Accepted 2026-10-07 -**Verification.** Analysis: `kernel/work/` review; Test: `wq/latency_measured` with the trace events. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-08). +**Verification.** Analysis: `kernel/work/workq.c` review; Test: `workq_thread_is_a_kernel_thread`; the model's queue thread follows the kernel's trace in the differential bridge. **Trace.** SPEC-006 §4.1, §4.3, §4.4 ### KRN-WQ-010 Lifecycle misuse **Statement.** Destroying a queue with queued or running items, flushing or synchronously cancelling from the queue's own thread, and returning from a handler while owning a mutex shall be misuse per SPEC-001 §5.3. **Rationale.** Each would leave work or locks in an undefined owner. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `misuse/wq_*` in checked and release configurations (TEST-010). +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-08) for destroy, flush and cancel_sync misuse and a handler returning with a mutex owned. +**Verification.** Test: `workq_destroy_refuses_a_busy_queue`; model scenarios; the owned-mutex check by inspection. **Trace.** SPEC-006 §6 ### KRN-WQ-011 Trace and statistics **Statement.** Submit, start with submit-to-start latency, end with duration, cancel, and delayed scheduling shall be trace events; maximum FIFO length, worst latency, and longest handler with its address shall be optional statistics per queue. **Rationale.** Work queue latency is the latency of every timer callback and bottom half. -**Status.** Accepted 2026-10-07 -**Verification.** Test: trace decoder; statistics API test. +**Status.** Accepted 2026-10-07; trace records implemented (A52); statistics are not. +**Verification.** Test: trace decoder (not written); statistics API test (no API). **Trace.** SPEC-006 §7; OBS-005 diff --git a/docs/specs/AMENDMENTS-M2.md b/docs/specs/AMENDMENTS-M2.md index 9121c14..f7a6f91 100644 --- a/docs/specs/AMENDMENTS-M2.md +++ b/docs/specs/AMENDMENTS-M2.md @@ -23,3 +23,9 @@ Corrections found while preparing the M2 primitives, where an accepted specifica | A45 | SPEC-007 §2.1, §9, KRN-IPC-021 | Public counts and sizes are `uint16_t`. With `CONFIG_EMB_IPC_SMALL` the object keeps 8-bit counts, so a capacity above 255 is an argument misuse and `CONFIG_EMB_MSGQ_MAX_ITEM` is limited to 255. | `kernel/include/embk/ipc.h` | | A46 | SPEC-007 §2.2, KRN-IPC-018 | A receive that refills the freed slot from a waiting sender does not signal the binding: the queue is never observed empty, which is the hand-off rule applied to the send side. | `kernel/ipc/msgq.c` | | A47 | SPEC-007 §1, §8, SPEC-015 | `send`, `receive`, `send_front` and `peek` are `thread isr` (waits from an ISR are `EMB_FAULT_API_CONTEXT`/`EMB_EPERM`); the `_until` forms are thread-only. The trace records are `EMB_TRACE_MSGQ_SEND` (send and send_front) and `EMB_TRACE_MSGQ_RECEIVE`, with the caller's index, the queue and the status of every outcome (A19); a waiting receiver or sender shows as the partner's wake record and the generic block record. `peek`, `count` and `free` are not traced. | `kernel/ipc/msgq.c`, `include/emb/trace.h` | +| A48 | SPEC-006 §4.1 | The public `emb_work_t` wraps generated opaque storage (`emb_work_storage_t`) instead of naming kernel list types; handlers reach their own structure with `EMB_CONTAINER_OF()`. `emb_work_state_t` is `EMB_WORK_IDLE`, `EMB_WORK_PENDING` (queued) or `EMB_WORK_RUNNING`; the resubmission flag stays internal. `emb_workq_attr_default()` is added. | `include/emb/work.h` | +| A49 | SPEC-006 §4.3, §6, KRN-WQ-006, KRN-WQ-010 | `cancel_sync` and `flush` are misuse from the queue thread they would wait on, whichever handler runs. Destroy is also misuse from that thread, while a thread waits on the queue and during another destroy; it waits, not cancelably, for the queue thread to exit. A `submit` or `flush` that races with a destroy returns `EMB_ESTATE`. An idle queue's `flush` returns at once; a timed-out or canceled flush removes its marker. Deadlines are fixed at the call (SPEC-003 §5.2). | `kernel/work/workq.c` | +| A50 | SPEC-006 §4.3, KRN-WQ-008 | The system work queue is linked only when `emb_workq_system()` is referenced and is then constructed by kernel initialization (ADR-048). `CONFIG_EMB_SYSTEM_WORKQ_PRIO` 0 selects the second-highest priority. | `kernel/work/system_workq.c` | +| A51 | SPEC-006 §5, SPEC-008 §5 | A queue's thread is a kernel thread (ADR-048): thread operations on its handle are refused, notifications to it are `EMB_FAULT_API_OWNER` misuse, and `emb_thread_exit()` from a handler is a context fault. | `kernel/thread/thread.c`, `kernel/notify/notify.c` | +| A52 | SPEC-006 §7, SPEC-015 | The trace records are `EMB_TRACE_WORK_SUBMIT`, `EMB_TRACE_WORK_RUN` (handler start and end, from which a decoder derives latency and duration), `EMB_TRACE_WORK_CANCEL`, and `EMB_TRACE_WORK_WAIT`, which carries the status of every outcome of `cancel_sync`, `flush` and destroy (A19). Statistics are not implemented. | `include/emb/trace.h` | +| A53 | SPEC-006 §8, §11, §12 decision 10 | Work queues require the list scheduler in M2: `CONFIG_EMB_WORKQ` depends on `!CONFIG_EMB_SCHED_TABLE`. The reference model covers them after all, since the M2 checklist asks for submit and resubmit states, cancel, flush and teardown to be modeled: a queue thread drains a FIFO of items whose handlers run model operations. | `kernel/Kconfig`, `tools/model/embmodel/kernel.py` | diff --git a/docs/specs/SPEC-006-notifications-and-work-queues.md b/docs/specs/SPEC-006-notifications-and-work-queues.md index 2a6d039..7482532 100644 --- a/docs/specs/SPEC-006-notifications-and-work-queues.md +++ b/docs/specs/SPEC-006-notifications-and-work-queues.md @@ -116,11 +116,8 @@ for (;;) { typedef void (*emb_work_fn_t)(emb_work_t *work); typedef struct emb_work { /* intrusive, caller-owned, statically allocatable (KRN-WQ-001) */ - embk_list_node_t node; - emb_work_fn_t fn; - emb_workq_t queue; /* the queue it is pending on or running on, else EMB_HANDLE_NULL */ - uint8_t state; /* EMB_WORK_IDLE | EMB_WORK_QUEUED | EMB_WORK_RUNNING, plus the RESUBMITTED flag */ -} emb_work_t; + emb_work_storage_t opaque_; /* node, handler, queue, state and the RESUBMITTED flag (A48) */ +} emb_work_t; /* emb_work_state_t: EMB_WORK_IDLE | EMB_WORK_PENDING | EMB_WORK_RUNNING */ typedef struct emb_dwork { /* delayed work: a work item plus an embedded timer (SPEC-003 §7) */ emb_work_t work; @@ -168,7 +165,7 @@ emb_status_t emb_dwork_cancel_sync(emb_dwork_t *dw, emb_timeout_t timeout); - **Flush.** Submits an internal marker item and waits for it: everything queued before the call has run when it returns. Flushing a queue from one of its own handlers is misuse. - **Delayed work.** `schedule` arms the embedded timer (SPEC-003 §7) whose callback is "submit the work item to `q`"; the timer itself runs in `ISR_CONTEXT` because its only action is the O(1) submit, so no second queue hop is paid. `schedule` on a pending delayed item (timer armed or work queued) returns `EMB_EEXIST` and changes nothing; `reschedule` replaces the delay (or, if the work is already queued, leaves it queued and returns `EMB_EEXIST`). `cancel` stops the timer if armed, else cancels the work; it reports `PENDING` for either pending form. Delayed work is the only timing mechanism work queues have (KRN-WQ-003). - **System work queue.** Exists when `CONFIG_EMB_SYSTEM_WORKQ=y` (default in `base` and above, off in `tiny`); priority `CONFIG_EMB_SYSTEM_WORKQ_PRIO` (default: the second-highest priority, because software timer callbacks run on it, SPEC-003 §7.3), stack `CONFIG_EMB_SYSTEM_WORKQ_STACK`. Applications should not block on it for long; the kernel's own uses are timer callbacks, deferred logging flush, and driver completion bottom halves (04 §3). -- **Destroy.** A queue with queued or running items: misuse (`EMB_EBUSY`). The queue's thread is terminated as part of destroy. +- **Destroy.** A queue with queued or running items: misuse (`EMB_EBUSY`). The queue's thread is terminated as part of destroy: destroy waits, not cancelably, until it has exited (A49, ADR-048). ### 4.4 Priorities and latency @@ -214,7 +211,7 @@ A UART driver's receive ISR fills a buffer and submits its bottom-half work item ## 11. Reference model -`tools/model/` gains `NotifySet(tid, bits)` (thread and ISR) and `NotifyWait(mask, mode, clear, timeout)` over the same `_block_section`, with the one-slot pseudo-queue, hand-off of satisfied bits, and `CLEAR` applied at wake; the semaphore binding already present now signals through the same `set` path. New invariants: a thread blocked with reason `NOTIFY` has an unsatisfied mask; a `set` that satisfies a waiting owner always wakes it in the same step. New scenarios: set in the window, set before wait (level), `ALL` across two sets, and the multi-source pattern of §3.3 with two bound semaphores (property: every given unit is eventually taken by the bound thread or remains in the count; at most one spurious pass per transition). Work queues are a thread pattern over notifications and are not modeled separately; their FIFO and state machine are tested in conformance. +`tools/model/` gains `NotifySet(tid, bits)` (thread and ISR) and `NotifyWait(mask, mode, clear, timeout)` over the same `_block_section`, with the one-slot pseudo-queue, hand-off of satisfied bits, and `CLEAR` applied at wake; the semaphore binding already present now signals through the same `set` path. New invariants: a thread blocked with reason `NOTIFY` has an unsatisfied mask; a `set` that satisfies a waiting owner always wakes it in the same step. New scenarios: set in the window, set before wait (level), `ALL` across two sets, and the multi-source pattern of §3.3 with two bound semaphores (property: every given unit is eventually taken by the bound thread or remains in the count; at most one spurious pass per transition). Work queues were first left to conformance tests; M2 models them too (A53): a queue thread drains a FIFO of items whose handlers run model operations, with invariants on the item states and the completion waiters. ## 12. Decisions taken at acceptance (2026-10-07) diff --git a/docs/user/feature-status.md b/docs/user/feature-status.md index aed2e6e..3bd2612 100644 --- a/docs/user/feature-status.md +++ b/docs/user/feature-status.md @@ -7,7 +7,7 @@ This guide describes the current 0.2 development implementation. The authoritati | Start and coordinate threads | Static storage, priorities, start, sleep, join/detach, cooperative cancellation | Isolated partitions, budgets, restart, SMP | | Protect or signal work | Mutexes, semaphores, per-thread notifications, event flags, condition variables | Barriers | | Transfer payloads | Copy-by-value message queues; application-owned data with an explicit synchronization protocol | Pipes, buffer pools, zero-copy ownership queues | -| Run periodic work | A thread using absolute sleep deadlines | Software timers and system work queues | +| Run periodic work | A thread using absolute sleep deadlines; work queues and the system work queue for deferred work (not with the tiny profile) | Software timers and delayed work | | Wait on several readiness sources | Semaphore, event flag and message queue bindings, with unbind, plus notification bits and rechecking; a bound thread cannot be destroyed | Pipe and pool bindings, restart-safe target lifetime | | Diagnose failures | State/priority queries, optional stack estimates, synchronous trace and fault hooks | Retained crash formats, CTF rings, full host tooling | | Develop without a board | Native Linux/POSIX virtual time and conformance suite | Physical-board timing, peripheral drivers and HIL evidence | diff --git a/docs/user/tasks.md b/docs/user/tasks.md index e1a5a4c..bc5d51e 100644 --- a/docs/user/tasks.md +++ b/docs/user/tasks.md @@ -89,3 +89,38 @@ Advancing `next` from the previous deadline keeps the schedule anchored. Sleepin Join waits for termination; it does not itself destroy the object or free caller storage. Use `emb_thread_destroy()` after a successful join when storage must be reused. A detached thread cannot be joined. Destroy can return `EMB_EBUSY` until the native port has completed switching the terminated thread out. Keep retry logic outside a critical section and do not reuse storage before success. A copied POINTER handle must not be used after destruction/reinitialization; generation-based stale-handle protection is planned. Cancellation is cooperative: `emb_thread_cancel()` requests it, and a cancellation point or cancelable wait returns `EMB_ECANCELED`. It does not kill the thread, destroy its objects, or repair shared state. Handle cancellation by releasing resources and choosing an exit result. In this closed example no one requests cancellation; the checked configuration faults on unexpected statuses through `EMB_CHECK`. In release, the macro returns the expression value without faulting, so a real worker must explicitly handle errors as well as expected cancellation/timeouts. See [Synchronization](synchronization.md) for ownership and [Debugging](debugging.md) for fault handling. + +## Deferred work on a work queue + +A work queue (`CONFIG_EMB_WORKQ`, not available with the tiny profile's table scheduler in this milestone) runs short jobs on a kernel thread, one at a time and in submission order. Submitting is O(1) and ISR-safe, so an interrupt can hand its slow part to a thread: + +```c +typedef struct { + emb_work_t work; + uint16_t sample; +} sensor_job_t; + +static emb_workq_storage_t jobs_storage; +EMB_THREAD_STACK(jobs_stack, 1024); +static emb_workq_t jobs; +static sensor_job_t job; + +static void process(emb_work_t *work) +{ + sensor_job_t *j = EMB_CONTAINER_OF(work, sensor_job_t, work); + /* thread context, at the queue's priority: may block, delays later jobs on this queue */ + (void)j->sample; +} + +emb_workq_attr_t attr; +emb_workq_attr_default(&attr); +attr.stack = jobs_stack; +attr.stack_size = sizeof(jobs_stack); +attr.priority = 4; +EMB_CHECK(emb_workq_init(&jobs_storage, &attr, &jobs)); +emb_work_init(&job.work, process); +/* in the interrupt handler: */ +(void)emb_work_submit(jobs, &job.work); /* EMB_EEXIST if it is already queued */ +``` + +An item is never queued twice and never runs concurrently with itself; submitting it while its handler runs queues it once more for after the handler returns. `emb_work_cancel()` reports whether the item was pending (now removed), running (it completes) or idle. Before reusing an item's storage, call `emb_work_cancel_sync()`, which also waits for a running handler. `emb_workq_flush()` waits until everything queued before the call has run. Calling either from the queue's own handlers would wait forever and is misuse, as is returning from a handler with a mutex held. The queue's thread belongs to the kernel: thread calls on it are refused and it cannot be notified directly. `emb_workq_destroy()` needs an idle queue and returns after the thread has exited. Referencing `emb_workq_system()` links in a shared system queue at a high priority; keep its handlers short. diff --git a/include/emb/api_ids.h b/include/emb/api_ids.h index dab2011..1cf52e0 100644 --- a/include/emb/api_ids.h +++ b/include/emb/api_ids.h @@ -118,5 +118,15 @@ #define EMB_API_ID_MSGQ_FREE 0x0070u #define EMB_API_ID_MSGQ_BIND_NOTIFY 0x0071u #define EMB_API_ID_MSGQ_UNBIND_NOTIFY 0x0072u +#define EMB_API_ID_WORKQ_ATTR_DEFAULT 0x0073u +#define EMB_API_ID_WORKQ_INIT 0x0074u +#define EMB_API_ID_WORKQ_DESTROY 0x0075u +#define EMB_API_ID_WORKQ_FLUSH 0x0076u +#define EMB_API_ID_WORKQ_SYSTEM 0x0077u +#define EMB_API_ID_WORK_INIT 0x0078u +#define EMB_API_ID_WORK_SUBMIT 0x0079u +#define EMB_API_ID_WORK_CANCEL 0x007Au +#define EMB_API_ID_WORK_CANCEL_SYNC 0x007Bu +#define EMB_API_ID_WORK_STATE 0x007Cu #endif /* EMB_API_IDS_H */ diff --git a/include/emb/emb.h b/include/emb/emb.h index fe257d3..becc325 100644 --- a/include/emb/emb.h +++ b/include/emb/emb.h @@ -30,5 +30,8 @@ #include #include #include +#if CONFIG_EMB_WORKQ +#include +#endif #endif /* EMB_EMB_H */ diff --git a/include/emb/trace.h b/include/emb/trace.h index 85f1406..245a87c 100644 --- a/include/emb/trace.h +++ b/include/emb/trace.h @@ -45,14 +45,19 @@ enum emb_trace_id { EMB_TRACE_PI_DEPTH = 26, /* a0 = index, a1 = depth reached */ EMB_TRACE_IDLE_ENTER = 27, EMB_TRACE_IDLE_EXIT = 28, - EMB_TRACE_TICK = 29, /* a0 = ticks low word */ - EMB_TRACE_SLEEP = 30, /* a0 = index, a1 = deadline low word */ - EMB_TRACE_EVENT_SET = 31, /* a0 = waiters woken, a1 = event address, a2 = bits after */ - EMB_TRACE_EVENT_WAIT = 32, /* a0 = index, a1 = event address, a2 = status */ - EMB_TRACE_COND_WAIT = 33, /* a0 = index, a1 = cond address, a2 = status after the relock */ - EMB_TRACE_COND_SIGNAL = 34, /* a0 = waiters woken, a1 = cond address, a2 = 1 for broadcast */ - EMB_TRACE_MSGQ_SEND = 35, /* a0 = index, a1 = queue address, a2 = status (send, front) */ - EMB_TRACE_MSGQ_RECEIVE = 36 /* a0 = index, a1 = queue address, a2 = status */ + EMB_TRACE_TICK = 29, /* a0 = ticks low word */ + EMB_TRACE_SLEEP = 30, /* a0 = index, a1 = deadline low word */ + EMB_TRACE_EVENT_SET = 31, /* a0 = waiters woken, a1 = event address, a2 = bits after */ + EMB_TRACE_EVENT_WAIT = 32, /* a0 = index, a1 = event address, a2 = status */ + EMB_TRACE_COND_WAIT = 33, /* a0 = index, a1 = cond address, a2 = status after the relock */ + EMB_TRACE_COND_SIGNAL = 34, /* a0 = waiters woken, a1 = cond address, a2 = 1 for broadcast */ + EMB_TRACE_MSGQ_SEND = 35, /* a0 = index, a1 = queue address, a2 = status (send, front) */ + EMB_TRACE_MSGQ_RECEIVE = 36, /* a0 = index, a1 = queue address, a2 = status */ + EMB_TRACE_WORK_SUBMIT = 37, /* a0 = queue thread index, a1 = item address, a2 = status */ + EMB_TRACE_WORK_RUN = 38, /* a0 = queue thread index, a1 = item address, a2 = 0 start, 1 end */ + EMB_TRACE_WORK_CANCEL = 39, /* a0 = index or 0xFF, a1 = item address, a2 = state it was in */ + EMB_TRACE_WORK_WAIT = 40 /* a0 = index, a1 = item or queue, a2 = status (cancel_sync, flush, + destroy) */ }; typedef struct emb_trace_event { diff --git a/include/emb/work.h b/include/emb/work.h new file mode 100644 index 0000000..ee347f3 --- /dev/null +++ b/include/emb/work.h @@ -0,0 +1,260 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* Work queues (SPEC-006 §4): a kernel thread runs submitted items one at a time, in order. */ +#ifndef EMB_WORK_H +#define EMB_WORK_H + +#include +#include +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +EMB_DECLARE_HANDLE(emb_workq_t); + +/* Work item states, as emb_work_state() and emb_work_cancel() report them. */ +typedef uint8_t emb_work_state_t; +#define EMB_WORK_IDLE ((emb_work_state_t)0u) /* not queued, not running */ +#define EMB_WORK_PENDING ((emb_work_state_t)1u) /* queued, its handler not started */ +#define EMB_WORK_RUNNING ((emb_work_state_t)2u) /* its handler runs */ + +/* A work item: caller-owned storage, embedded in the application's own structure and + * reached from the handler with EMB_CONTAINER_OF(). Initialize it with emb_work_init(). */ +typedef struct emb_work { + emb_work_storage_t opaque_; +} emb_work_t; + +typedef void (*emb_work_fn_t)(emb_work_t *work); + +typedef struct emb_workq_attr { + const char *name; + void *stack; /* required, caller-provided (KRN-THR-007); see EMB_THREAD_STACK() */ + size_t stack_size; + uint8_t priority; /* of the queue's thread */ + uint8_t reserved_[3]; /* explicit padding */ +} emb_workq_attr_t; + +/** + * emb_workq_attr_default() - Fill work queue attributes with defaults. + * @out_attr: Receives name and stack NULL, stack size 0 and the lowest application + * priority, which init refuses until a stack is set; NULL is a no-op. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_WORKQ + * @req API-020 KRN-WQ-001 + * @since 0.3 + * @stable no + */ +void emb_workq_attr_default(emb_workq_attr_t *out_attr); + +/** + * emb_workq_init() - Construct a work queue and start its thread. + * @storage: Aligned caller-owned storage, never initialized or destroyed; retain it until + * successful destruction. + * @attr: Required attributes, copied; the stack stays borrowed. Keep name storage alive. + * @out_workq: Required output handle; cleared before argument validation. + * + * @ctx prekernel thread + * @blocks no + * @time as emb_thread_init(); O(active work queues) list lookup with + * CONFIG_EMB_OBJ_LIST + * @owns borrows:storage borrows:stack + * @config CONFIG_EMB_WORKQ + * @req API-021 KRN-WQ-009 KRN-THR-007 KRN-OBJ-003 KRN-OBJ-007 + * @since 0.3 + * @stable no + * + * The queue's thread is a kernel thread: thread calls on its handle return EMB_EPERM and + * notifications to it are refused, so only the queue's own operations reach it. Handlers + * run on it at @attr's priority. + * + * Return: EMB_OK; EMB_EINVAL for bad storage, attributes or output, or for a priority or + * stack emb_thread_init() refuses; EMB_EBUSY when @storage still holds a work + * queue that was not destroyed (misuse); the runtime statuses of + * emb_thread_init(). + */ +emb_status_t emb_workq_init(emb_workq_storage_t *storage, const emb_workq_attr_t *attr, + emb_workq_t *out_workq); + +/** + * emb_workq_destroy() - Stop an idle work queue and wait for its thread to exit. + * @workq: Initialized work queue with no item queued or running and no waiter. + * + * @ctx thread + * @blocks until the queue's thread exits + * @time O(1) plus the queue thread's exit + * @owns gives:storage gives:stack + * @config CONFIG_EMB_WORKQ + * @req KRN-OBJ-001 KRN-WQ-010 + * @since 0.3 + * @stable no + * + * The wait is not a cancellation point: a cancel sent meanwhile is delivered at the next + * one. A submit or flush racing with the destroy returns EMB_ESTATE. + * + * Return: EMB_OK; EMB_EBUSY (misuse) while an item is queued or running, a thread waits + * on the queue, or another destroy is in progress, and when called from the + * queue's own thread; EMB_EINVAL for a bad handle; EMB_EPERM outside thread context. + */ +emb_status_t emb_workq_destroy(emb_workq_t workq); + +/** + * emb_workq_flush() - Wait until every item queued before the call has run. + * @workq: Initialized work queue. + * @timeout: EMB_NO_WAIT, EMB_WAIT_FOREVER or EMB_TIMEOUT(duration). + * + * @ctx thread + * @blocks timeout + * @time O(1) enqueue of a marker; O(waiters + timeouts) finite wait enqueue + * @owns none + * @config CONFIG_EMB_WORKQ + * @req KRN-WQ-006 KRN-WQ-010 KRN-WAIT-012 + * @since 0.3 + * @stable no + * + * An idle queue returns at once. Otherwise a marker queued behind the existing items + * wakes the caller when the queue's thread reaches it; a timeout or cancel removes it. + * + * Return: EMB_OK; EMB_ETIMEDOUT; EMB_ECANCELED; EMB_ESTATE while the queue is being + * destroyed; EMB_EPERM (misuse) from the queue's own thread (it would wait for + * itself) or outside thread context; EMB_EINVAL for a bad handle. + */ +emb_status_t emb_workq_flush(emb_workq_t workq, emb_timeout_t timeout); + +#if CONFIG_EMB_SYSTEM_WORKQ +/** + * emb_workq_system() - The system work queue. + * + * @ctx thread isr + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_SYSTEM_WORKQ + * @req KRN-WQ-008 + * @since 0.3 + * @stable no + * + * The queue, its thread and its stack are linked into an image only when this function + * is referenced (ADR-048); kernel initialization then constructs it at + * CONFIG_EMB_SYSTEM_WORKQ_PRIO with a CONFIG_EMB_SYSTEM_WORKQ_STACK-byte stack. + * + * Return: the queue's handle; a null handle before emb_kernel_init(). + */ +emb_workq_t emb_workq_system(void); +#endif + +/** + * emb_work_init() - Initialize a work item that runs @fn. + * @work: Caller-owned item, not queued or running; NULL is a no-op. + * @fn: Handler, run on the queue's thread with @work. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_WORKQ + * @req KRN-WQ-001 + * @since 0.3 + * @stable no + * + * Reinitializing an item that is queued or running corrupts its queue: cancel it with + * emb_work_cancel_sync() first. + */ +void emb_work_init(emb_work_t *work, emb_work_fn_t fn); + +/** + * emb_work_submit() - Queue an idle item at the tail of @workq. + * @workq: Initialized work queue. + * @work: Initialized item. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(1), plus the queue thread's wake + * @owns none + * @config CONFIG_EMB_WORKQ + * @req KRN-WQ-001 KRN-WQ-002 KRN-WQ-005 + * @since 0.3 + * @stable no + * + * An item is never queued twice and never runs concurrently with itself. A submit while + * its handler runs on @workq is kept: the item is queued again when the handler returns. + * + * Return: EMB_OK; EMB_EEXIST when already queued (nothing changes); EMB_EBUSY while its + * handler runs on another queue; EMB_ESTATE while @workq is being destroyed; + * EMB_EINVAL for a bad handle or an item that was not initialized. + */ +emb_status_t emb_work_submit(emb_workq_t workq, emb_work_t *work); + +/** + * emb_work_cancel() - Remove a queued item; never waits. + * @work: Initialized item. + * @out_state: Receives the state it was in; may be NULL. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_WORKQ + * @req KRN-WQ-004 + * @since 0.3 + * @stable no + * + * EMB_WORK_PENDING: the item was unlinked and will not run. EMB_WORK_RUNNING: its handler + * completes and a resubmission made while it runs is dropped. EMB_WORK_IDLE: nothing to do. + * + * Return: EMB_OK; EMB_EINVAL for an item that was not initialized. + */ +emb_status_t emb_work_cancel(emb_work_t *work, emb_work_state_t *out_state); + +/** + * emb_work_cancel_sync() - Cancel, then wait until a running handler has returned. + * @work: Initialized item. + * @timeout: EMB_NO_WAIT, EMB_WAIT_FOREVER or EMB_TIMEOUT(duration). + * + * @ctx thread + * @blocks timeout + * @time O(1); O(waiters + timeouts) finite wait enqueue + * @owns none + * @config CONFIG_EMB_WORKQ + * @req KRN-WQ-006 KRN-WQ-010 KRN-WAIT-012 + * @since 0.3 + * @stable no + * + * On EMB_OK the item is idle unless it was submitted again after the call began, and its + * storage may be reused once no one submits it. + * + * Return: EMB_OK; EMB_ETIMEDOUT with the handler still running; EMB_ECANCELED; EMB_EPERM + * (misuse) from the queue thread the item is queued or running on (it would wait + * for itself) or outside thread context; EMB_EINVAL for an item that was not + * initialized. + */ +emb_status_t emb_work_cancel_sync(emb_work_t *work, emb_timeout_t timeout); + +/** + * emb_work_state() - Snapshot of an item's state; EMB_WORK_IDLE for one not initialized. + * @work: Initialized item. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_WORKQ + * @req KRN-WQ-004 + * @since 0.3 + * @stable no + */ +emb_work_state_t emb_work_state(const emb_work_t *work); + +#ifdef __cplusplus +} +#endif + +#endif /* EMB_WORK_H */ diff --git a/kernel/CMakeLists.txt b/kernel/CMakeLists.txt index a33397e..6b97a05 100644 --- a/kernel/CMakeLists.txt +++ b/kernel/CMakeLists.txt @@ -33,6 +33,12 @@ endif() if(CONFIG_EMB_MSGQ) list(APPEND EMB_KERNEL_SOURCES ipc/msgq.c) endif() +if(CONFIG_EMB_WORKQ) + list(APPEND EMB_KERNEL_SOURCES work/workq.c) +endif() +if(CONFIG_EMB_SYSTEM_WORKQ) + list(APPEND EMB_KERNEL_SOURCES work/system_workq.c) # linked only when referenced (ADR-048) +endif() add_library(emb_kernel STATIC ${EMB_KERNEL_SOURCES}) emb_use_kernel_compiler(emb_kernel) diff --git a/kernel/Kconfig b/kernel/Kconfig index 7ebc489..bce7769 100644 --- a/kernel/Kconfig +++ b/kernel/Kconfig @@ -241,6 +241,35 @@ config EMB_IPC_SMALL endmenu +menu "Work queues" + +config EMB_WORKQ + bool "Work queues (SPEC-006 §4)" + depends on EMB_NOTIFY && !EMB_SCHED_TABLE + default y + help + A kernel thread per queue runs submitted items in order. The tiny profile's table + scheduler has no work queues in M2. + +config EMB_SYSTEM_WORKQ + bool "The system work queue, linked only when referenced (ADR-048)" + depends on EMB_WORKQ + default y + +config EMB_SYSTEM_WORKQ_PRIO + int "System work queue priority; 0 selects the second-highest priority" + depends on EMB_SYSTEM_WORKQ + default 0 + range 0 255 + +config EMB_SYSTEM_WORKQ_STACK + int "System work queue stack size, bytes" + depends on EMB_SYSTEM_WORKQ + default 2048 + range EMB_ARCH_STACK_MIN 65536 + +endmenu + menu "Objects" choice EMB_HANDLE_MODEL_CHOICE diff --git a/kernel/include/embk/kernel.h b/kernel/include/embk/kernel.h index 5c882ac..ec11ef6 100644 --- a/kernel/include/embk/kernel.h +++ b/kernel/include/embk/kernel.h @@ -42,6 +42,7 @@ #define EMBK_OBJ_EVENT ((uint8_t)0x54u) #define EMBK_OBJ_COND ((uint8_t)0x55u) #define EMBK_OBJ_MSGQ ((uint8_t)0x56u) +#define EMBK_OBJ_WORKQ ((uint8_t)0x57u) #define EMBK_OBJ_UNINIT ((uint8_t)0u) #define EMBK_OBJ_ACTIVE ((uint8_t)1u) diff --git a/kernel/include/embk/thread.h b/kernel/include/embk/thread.h index 8ea4115..c60ccbd 100644 --- a/kernel/include/embk/thread.h +++ b/kernel/include/embk/thread.h @@ -18,6 +18,19 @@ bool embk_thread_take_cancel_locked(embk_thread_t *self); extern embk_list_t embk_thread_all; /* lock: scheduler domain */ #endif +#if CONFIG_EMB_WORKQ +/* Construct and start @t as a kernel thread: no handle operation reaches it, it is never + * canceled, and only the kernel ends it. The application thread checks apply; faults + * name @api_id. */ +emb_status_t embk_thread_init_kernel(uint16_t api_id, embk_thread_t *t, + const emb_thread_attr_t *attr, emb_thread_entry_t entry, + void *arg); +/* The current kernel thread terminates with code 0 (SPEC-008 §5); never returns. */ +EMB_NORETURN void embk_thread_exit_kernel(void); +/* Release a kernel thread after its joiner woke; lock not held. */ +void embk_thread_destroy_kernel(embk_thread_t *t); +#endif + #if CONFIG_EMB_NOTIFY /* OR @bits into @t's word and wake it if satisfied; lock held; kernel bits allowed. */ void embk_notify_set_locked(embk_thread_t *t, emb_notify_bits_t bits); diff --git a/kernel/include/embk/work.h b/kernel/include/embk/work.h new file mode 100644 index 0000000..c5a4f6e --- /dev/null +++ b/kernel/include/embk/work.h @@ -0,0 +1,44 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* Work queue objects (SPEC-006 §4). Included by the layout probe, so it names the public + * work item only as an incomplete type. */ +#ifndef EMBK_WORK_H +#define EMBK_WORK_H + +#include + +#if CONFIG_EMB_WORKQ +struct emb_work; +struct embk_workq; + +#define EMBK_WORK_TAG ((uint8_t)0x5Au) /* set by emb_work_init() */ +#define EMBK_WORK_RESUBMITTED ((uint8_t)0x01u) /* submitted while running: queue it again */ +#define EMBK_WORK_MARKER ((uint8_t)0x02u) /* a flush marker on the flusher's stack */ + +typedef struct embk_work { + embk_list_node_t node; /* in the queue's FIFO while PENDING */ + void (*fn)(struct emb_work *work); + struct embk_workq *queue; /* the queue it is pending or running on, else NULL */ + uint8_t tag; /* EMBK_WORK_TAG once initialized */ + uint8_t state; /* EMB_WORK_IDLE | EMB_WORK_PENDING | EMB_WORK_RUNNING */ + uint8_t flags; /* EMBK_WORK_RESUBMITTED | EMBK_WORK_MARKER */ +} embk_work_t; + +typedef struct embk_workq { + embk_obj_t obj; + embk_thread_t thread; /* the queue's kernel thread */ + embk_list_t fifo; /* pending items and flush markers, in submission order */ + embk_wait_queue_t done_q; /* cancel_sync and flush; wake_data holds the awaited item */ + embk_work_t *running; /* the item whose handler runs, or NULL */ + uint8_t stopping; /* destroy has begun: the thread exits at an empty FIFO */ +#if CONFIG_EMB_OBJ_LIST + embk_list_node_t all_node; /* in embk_workq_all from init to destroy */ +#endif +} embk_workq_t; + +#if CONFIG_EMB_OBJ_LIST +extern embk_list_t embk_workq_all; /* every active work queue; lock: scheduler domain */ +#endif +#endif + +#endif /* EMBK_WORK_H */ diff --git a/kernel/notify/notify.c b/kernel/notify/notify.c index 02fb97f..577f964 100644 --- a/kernel/notify/notify.c +++ b/kernel/notify/notify.c @@ -35,6 +35,11 @@ emb_status_t emb_notify_set(emb_thread_t thread, emb_notify_bits_t bits) embk_thread_t *t = embk_thread_from_handle(thread); emb_irq_key_t key; EMBK_API_REQUIRE(EMB_API_ID_NOTIFY_SET, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); +#if CONFIG_EMB_WORKQ + /* a work queue thread's word belongs to the kernel (SPEC-006 §5) */ + EMBK_API_REQUIRE(EMB_API_ID_NOTIFY_SET, (t->tflags & EMBK_THREAD_KERNEL) == 0u, + EMB_FAULT_API_OWNER, EMB_EPERM, 1u); +#endif EMBK_API_REQUIRE(EMB_API_ID_NOTIFY_SET, bits != 0u && (bits & (emb_notify_bits_t)~EMB_NOTIFY_APP_MASK) == 0u, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); diff --git a/kernel/object/storage_probe.c b/kernel/object/storage_probe.c index d37e888..437a94c 100644 --- a/kernel/object/storage_probe.c +++ b/kernel/object/storage_probe.c @@ -10,6 +10,7 @@ #include #include #include +#include #include @@ -39,4 +40,8 @@ void embk_storage_probe(void) #if CONFIG_EMB_MSGQ PROBE(msgq, embk_msgq_t); #endif +#if CONFIG_EMB_WORKQ + PROBE(work, embk_work_t); + PROBE(workq, embk_workq_t); +#endif } diff --git a/kernel/thread/thread.c b/kernel/thread/thread.c index 26e9b31..a21be9e 100644 --- a/kernel/thread/thread.c +++ b/kernel/thread/thread.c @@ -149,52 +149,33 @@ void emb_thread_attr_default(emb_thread_attr_t *out_attr) out_attr->reserved_ = 0u; } -emb_status_t emb_thread_init(emb_thread_storage_t *storage, const emb_thread_attr_t *attr, - emb_thread_entry_t entry, void *arg, emb_thread_t *out_thread) +/* The checks and construction an application thread and a kernel thread share (SPEC-008 + * §4); @api_id names the caller's API in faults, @kernel marks a kernel thread. */ +static emb_status_t thread_construct(uint16_t api_id, embk_thread_t *t, + const emb_thread_attr_t *attr, emb_thread_entry_t entry, + void *arg, bool kernel) { - embk_thread_t *t; emb_irq_key_t key; uint8_t index; - if (out_thread != NULL) { - out_thread->raw = 0u; - } - EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_THREAD_INIT); - EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, embk_cpu.kernel_state != EMBK_KERNEL_UNINIT, - EMB_FAULT_API_LIFECYCLE, EMB_ESTATE, 0u); - EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, storage != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, - 1u); - EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, attr != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); - EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, entry != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 3u); - EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, out_thread != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, - 5u); - EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, + EMBK_API_REQUIRE(api_id, attr->priority >= EMB_PRIORITY_MIN && attr->priority <= EMB_PRIORITY_MAX, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); - EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, - attr->stack != NULL && - ((uintptr_t)attr->stack % (uintptr_t)EMB_STACK_ALIGN) == 0u, - EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); + EMBK_API_REQUIRE( + api_id, attr->stack != NULL && ((uintptr_t)attr->stack % (uintptr_t)EMB_STACK_ALIGN) == 0u, + EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); { size_t min_stack = EMB_THREAD_STACK_MIN; if (min_stack < EMBK_STACK_GUARD_BYTES + (size_t)CONFIG_EMB_ARCH_CONTEXT_FRAME) { min_stack = EMBK_STACK_GUARD_BYTES + (size_t)CONFIG_EMB_ARCH_CONTEXT_FRAME; } - EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, attr->stack_size >= min_stack, - EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); + EMBK_API_REQUIRE(api_id, attr->stack_size >= min_stack, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, + 2u); } - EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, - ((uintptr_t)storage % (uintptr_t)EMB_THREAD_STORAGE_ALIGN) == 0u, - EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); - EMB_STATIC_ASSERT(sizeof(embk_thread_t) <= EMB_THREAD_STORAGE_SIZE, - "generated storage too small"); - - t = (embk_thread_t *)(void *) - storage; /* the storage union is sized and aligned for it (CS-4.5) */ key = embk_lock_sched(); if (storage_is_live(t)) { embk_unlock_sched(key); - EMBK_API_MISUSE(EMB_API_ID_THREAD_INIT, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); + EMBK_API_MISUSE(api_id, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); } #if CONFIG_EMB_SCHED_TABLE if (EMBK_TABLE_SLOT(attr->priority) != NULL) { @@ -214,14 +195,16 @@ emb_status_t emb_thread_init(emb_thread_storage_t *storage, const emb_thread_att t->eff_prio = attr->priority; t->wait_state = EMBK_WAIT_BLOCKED; /* INACTIVE: BLOCKED with reason START (KRN-THR-009) */ t->wait_reason = EMB_WAIT_REASON_START; - t->tflags = 0u; + t->tflags = kernel ? EMBK_THREAD_KERNEL : 0u; if ((attr->flags & EMB_THREAD_DETACHED) != 0u) { t->tflags |= EMBK_THREAD_DETACHED; } if ((attr->flags & EMB_THREAD_CRITICAL) != 0u) { t->tflags |= EMBK_THREAD_CRITICAL; } - t->cancel_disable = ((attr->flags & EMB_THREAD_CANCEL_DISABLED) != 0u) ? 1u : 0u; + t->cancel_disable = kernel ? UINT8_MAX + : ((attr->flags & EMB_THREAD_CANCEL_DISABLED) != 0u) ? 1u + : 0u; t->stack_base = (uint8_t *)attr->stack; t->stack_size = attr->stack_size; #if CONFIG_EMB_CHECKED || CONFIG_EMB_STACK_STATS @@ -237,17 +220,46 @@ emb_status_t emb_thread_init(emb_thread_storage_t *storage, const emb_thread_att embk_list_append(&embk_thread_all, &t->all_node); #endif embk_unlock_sched(key); - *out_thread = embk_thread_handle(t); EMBK_TRACE(EMB_TRACE_THREAD_INIT, embk_thread_index(t), attr->priority, 0u); return EMB_OK; } -emb_status_t emb_thread_start(emb_thread_t thread) +emb_status_t emb_thread_init(emb_thread_storage_t *storage, const emb_thread_attr_t *attr, + emb_thread_entry_t entry, void *arg, emb_thread_t *out_thread) { - embk_thread_t *t = embk_thread_from_handle(thread); - emb_irq_key_t key; - EMBK_API_REQUIRE(EMB_API_ID_THREAD_START, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); - key = embk_lock_sched(); + embk_thread_t *t; + emb_status_t st; + + if (out_thread != NULL) { + out_thread->raw = 0u; + } + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_THREAD_INIT); + EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, embk_cpu.kernel_state != EMBK_KERNEL_UNINIT, + EMB_FAULT_API_LIFECYCLE, EMB_ESTATE, 0u); + EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, storage != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, + 1u); + EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, attr != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); + EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, entry != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 3u); + EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, out_thread != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, + 5u); + EMBK_API_REQUIRE(EMB_API_ID_THREAD_INIT, + ((uintptr_t)storage % (uintptr_t)EMB_THREAD_STORAGE_ALIGN) == 0u, + EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + EMB_STATIC_ASSERT(sizeof(embk_thread_t) <= EMB_THREAD_STORAGE_SIZE, + "generated storage too small"); + + t = (embk_thread_t *)(void *) + storage; /* the storage union is sized and aligned for it (CS-4.5) */ + st = thread_construct(EMB_API_ID_THREAD_INIT, t, attr, entry, arg, false); + if (st == EMB_OK) { + *out_thread = embk_thread_handle(t); + } + return st; +} + +static emb_status_t thread_start(embk_thread_t *t) +{ + emb_irq_key_t key = embk_lock_sched(); if ((t->tflags & EMBK_THREAD_STARTED) != 0u) { embk_unlock_sched(key); return EMB_ESTATE; @@ -264,6 +276,13 @@ emb_status_t emb_thread_start(emb_thread_t thread) return EMB_OK; } +emb_status_t emb_thread_start(emb_thread_t thread) +{ + embk_thread_t *t = embk_thread_from_handle(thread); + EMBK_API_REQUIRE(EMB_API_ID_THREAD_START, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + return thread_start(t); +} + emb_thread_t emb_thread_self(void) { emb_thread_t h; @@ -276,16 +295,12 @@ emb_thread_t emb_thread_self(void) /* ---- termination (SPEC-008 §5) ------------------------------------------------------ */ -void emb_thread_exit(int code) +static EMB_NORETURN void thread_exit(int code) { - embk_thread_t *self; + embk_thread_t *self = embk_cpu.current; embk_thread_t *joiner; emb_irq_key_t key; - if (!embk_in_thread() || embk_cpu.current == EMBK_IDLE) { - embk_fault_raise_api(EMB_API_ID_THREAD_EXIT, EMB_FAULT_API_CONTEXT, 0u, 0u, EMBK_WHERE); - } - self = embk_cpu.current; key = embk_lock_sched(); self->cancel_disable = UINT8_MAX; /* step 1: no further delivery */ #if CONFIG_EMB_MUTEX @@ -314,6 +329,15 @@ void emb_thread_exit(int code) embk_sched_switch_final(); /* step 5 is the port's switch-out completion */ } +void emb_thread_exit(int code) +{ + /* idle and the work queue threads are kernel threads: they never exit on request */ + if (!embk_in_thread() || (embk_cpu.current->tflags & EMBK_THREAD_KERNEL) != 0u) { + embk_fault_raise_api(EMB_API_ID_THREAD_EXIT, EMB_FAULT_API_CONTEXT, 0u, 0u, EMBK_WHERE); + } + thread_exit(code); +} + void embk_thread_launch(emb_thread_entry_t entry, void *arg) { /* A fresh context starts with interrupts enabled (SPEC-012 §4.2) and at critical @@ -805,6 +829,19 @@ void emb_thread_foreach(emb_thread_visit_t cb, void *arg) } #endif +/* The end of a thread object: its identity and table slot are free again. */ +static void thread_release_locked(embk_thread_t *t) +{ +#if CONFIG_EMB_THREAD_LIST + embk_list_remove(&t->all_node); +#endif +#if CONFIG_EMB_SCHED_TABLE + EMBK_TABLE_SLOT(t->base_prio) = NULL; +#endif + index_release(embk_thread_index(t)); + t->obj.state = EMBK_OBJ_UNINIT; +} + emb_status_t emb_thread_destroy(emb_thread_t thread) { embk_thread_t *t = embk_thread_from_handle(thread); @@ -825,14 +862,36 @@ emb_status_t emb_thread_destroy(emb_thread_t thread) embk_unlock_sched(key); EMBK_API_MISUSE(EMB_API_ID_THREAD_DESTROY, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); } -#if CONFIG_EMB_THREAD_LIST - embk_list_remove(&t->all_node); -#endif -#if CONFIG_EMB_SCHED_TABLE - EMBK_TABLE_SLOT(t->base_prio) = NULL; -#endif - index_release(embk_thread_index(t)); - t->obj.state = EMBK_OBJ_UNINIT; + thread_release_locked(t); embk_unlock_sched(key); return EMB_OK; } + +#if CONFIG_EMB_WORKQ +/* ---- kernel threads (the work queues' threads, SPEC-006 §4) ------------------------- */ + +emb_status_t embk_thread_init_kernel(uint16_t api_id, embk_thread_t *t, + const emb_thread_attr_t *attr, emb_thread_entry_t entry, + void *arg) +{ + emb_status_t st = thread_construct(api_id, t, attr, entry, arg, true); + if (st == EMB_OK) { + (void)thread_start(t); + } + return st; +} + +void embk_thread_exit_kernel(void) +{ + thread_exit(0); +} + +void embk_thread_destroy_kernel(embk_thread_t *t) +{ + emb_irq_key_t key = embk_lock_sched(); + /* joined, and switched out before its joiner ran */ + EMBK_ASSERT((t->tflags & EMBK_THREAD_JOINED) != 0u && emb_arch_switch_out_done(t)); + thread_release_locked(t); + embk_unlock_sched(key); +} +#endif diff --git a/kernel/work/system_workq.c b/kernel/work/system_workq.c new file mode 100644 index 0000000..44d0110 --- /dev/null +++ b/kernel/work/system_workq.c @@ -0,0 +1,45 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* + * The system work queue (SPEC-006 §4.3). Its own object file: an image links the queue, + * its thread and its stack only when it references emb_workq_system(), whose init-table + * entry then constructs it in emb_kernel_init() (ADR-048). + */ +#include +#include +#include +#include + +#if CONFIG_EMB_SYSTEM_WORKQ + +#if CONFIG_EMB_SYSTEM_WORKQ_PRIO == 0 +#define SYSTEM_WORKQ_PRIO ((uint8_t)(EMB_PRIORITY_MAX - 1u)) /* the second-highest priority */ +#else +#define SYSTEM_WORKQ_PRIO ((uint8_t)CONFIG_EMB_SYSTEM_WORKQ_PRIO) +EMB_STATIC_ASSERT(CONFIG_EMB_SYSTEM_WORKQ_PRIO < CONFIG_EMB_PRIORITY_COUNT, + "CONFIG_EMB_SYSTEM_WORKQ_PRIO is not an application priority"); +#endif + +static emb_workq_storage_t system_storage; +EMB_THREAD_STACK(system_stack, CONFIG_EMB_SYSTEM_WORKQ_STACK); +static emb_workq_t system_queue; + +static void system_workq_init(void) +{ + emb_workq_attr_t attr; + emb_workq_attr_default(&attr); + attr.name = "sysworkq"; + attr.stack = system_stack; + attr.stack_size = sizeof(system_stack); + attr.priority = SYSTEM_WORKQ_PRIO; + (void)emb_check_status(emb_workq_init(&system_storage, &attr, &system_queue), + "system work queue"); +} +EMB_INIT_TABLE_ENTRY(system_workq_init); + +emb_workq_t emb_workq_system(void) +{ + return system_queue; +} + +#endif /* CONFIG_EMB_SYSTEM_WORKQ */ diff --git a/kernel/work/workq.c b/kernel/work/workq.c new file mode 100644 index 0000000..beac3d2 --- /dev/null +++ b/kernel/work/workq.c @@ -0,0 +1,430 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* + * Work queues (SPEC-006 §4): a kernel thread and an intrusive FIFO. A submit appends the + * item and sets EMB_NOTIFY_K_WORK on the queue's thread, which drains the FIFO one item + * at a time, marking it RUNNING while its handler runs outside the critical section. A + * submit during the handler marks the item for one more run, so an item never runs + * concurrently with itself and no submission is lost. cancel_sync and flush wait on the + * completion queue with the awaited item (a stack marker for flush) in wake_data. + */ +#include +#include +#include + +#include +#include +#include +#include +#include + +#if CONFIG_EMB_WORKQ + +EMB_STATIC_ASSERT(sizeof(embk_workq_t) <= EMB_WORKQ_STORAGE_SIZE, "generated storage too small"); +EMB_STATIC_ASSERT(sizeof(embk_work_t) <= sizeof(emb_work_t), "generated storage too small"); + +#if CONFIG_EMB_OBJ_LIST +embk_list_t embk_workq_all = EMBK_LIST_INITIALIZER(embk_workq_all); /* usable before kernel init */ +#endif + +static EMB_INLINE embk_workq_t *workq_from_handle(emb_workq_t h) +{ + return (embk_workq_t *)embk_obj_from_raw(h.raw, EMBK_OBJ_WORKQ, offsetof(embk_workq_t, obj)); +} + +static EMB_INLINE embk_work_t *work_of(const emb_work_t *work) +{ + embk_work_t *w = (embk_work_t *)(void *)(uintptr_t)work; /* the storage holds it (CS-4.5) */ + return (w != NULL && w->tag == EMBK_WORK_TAG) ? w : NULL; +} + +/* The index the trace records for the caller: none before the kernel runs. */ +static EMB_INLINE uintptr_t caller_index(void) +{ + return (embk_cpu.current != NULL) ? embk_thread_index(embk_cpu.current) : 0xFFu; +} + +/* Wake every completion waiter for @w, in queue order; lock held. */ +static void wake_done_locked(embk_workq_t *q, const embk_work_t *w) +{ + embk_wait_iter_t it; + embk_thread_t *t; + embk_wait_iter_begin(&q->done_q, &it); + while ((t = embk_wait_iter_next(&q->done_q, &it)) != NULL) { + if (t->wake_data == (uintptr_t)w) { + (void)embk_wait_wake(t, EMBK_WAKE_SATISFIED, 0u); + } + } +} + +/* Unlink a pending item: it will not run. Lock held. */ +static void unlink_locked(embk_work_t *w) +{ + embk_list_remove(&w->node); + w->state = EMB_WORK_IDLE; + w->queue = NULL; +} + +/* The queue's thread (KRN-WQ-009): drain, then wait for K_WORK; exit once stopping. */ +static void workq_main(void *arg) +{ + embk_workq_t *q = (embk_workq_t *)arg; + for (;;) { + emb_irq_key_t key = embk_lock_object(q); + embk_list_node_t *n = embk_list_pop_first(&q->fifo); + embk_work_t *w; + if (n == NULL) { + bool stop = q->stopping != 0u; + embk_unlock_object(q, key); + if (stop) { + embk_thread_exit_kernel(); /* wakes the destroying thread */ + } + (void)emb_notify_wait(EMB_NOTIFY_K_WORK, EMB_NOTIFY_ANY | EMB_NOTIFY_CLEAR, + EMB_WAIT_FOREVER, NULL); + continue; + } + w = EMB_CONTAINER_OF(n, embk_work_t, node); + if ((w->flags & EMBK_WORK_MARKER) != 0u) { + w->state = EMB_WORK_IDLE; /* everything queued before the flush has run */ + wake_done_locked(q, w); + embk_sched_reschedule_if_needed(); + embk_unlock_object(q, key); + continue; + } + w->state = EMB_WORK_RUNNING; + q->running = w; + EMBK_TRACE(EMB_TRACE_WORK_RUN, embk_thread_index(&q->thread), w, 0u); + embk_unlock_object(q, key); + w->fn((emb_work_t *)(void *)w); + key = embk_lock_object(q); + EMBK_TRACE(EMB_TRACE_WORK_RUN, embk_thread_index(&q->thread), w, 1u); +#if CONFIG_EMB_MUTEX + if (q->thread.owned != NULL) { + /* the next handler would inherit the lock (SPEC-006 §6) */ + embk_fault_raise_api(EMB_API_ID_WORK_SUBMIT, EMB_FAULT_API_LIFECYCLE, 0u, 0u, + EMBK_WHERE); + } +#endif + q->running = NULL; + if ((w->flags & EMBK_WORK_RESUBMITTED) != 0u) { + w->flags &= (uint8_t)~EMBK_WORK_RESUBMITTED; + w->state = EMB_WORK_PENDING; + embk_list_append(&q->fifo, &w->node); + } else { + w->state = EMB_WORK_IDLE; + w->queue = NULL; + } + wake_done_locked(q, w); + embk_sched_reschedule_if_needed(); + embk_unlock_object(q, key); + } +} + +void emb_workq_attr_default(emb_workq_attr_t *out_attr) +{ + if (out_attr == NULL) { + return; + } + out_attr->name = NULL; + out_attr->stack = NULL; + out_attr->stack_size = 0u; + out_attr->priority = EMB_PRIORITY_MIN; + out_attr->reserved_[0] = 0u; + out_attr->reserved_[1] = 0u; + out_attr->reserved_[2] = 0u; +} + +emb_status_t emb_workq_init(emb_workq_storage_t *storage, const emb_workq_attr_t *attr, + emb_workq_t *out_workq) +{ + embk_workq_t *q; + emb_thread_attr_t ta; + emb_irq_key_t key; + emb_status_t st; + if (out_workq != NULL) { + out_workq->raw = 0u; + } + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_WORKQ_INIT); + EMBK_API_REQUIRE(EMB_API_ID_WORKQ_INIT, embk_cpu.kernel_state != EMBK_KERNEL_UNINIT, + EMB_FAULT_API_LIFECYCLE, EMB_ESTATE, 0u); + EMBK_API_REQUIRE(EMB_API_ID_WORKQ_INIT, storage != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, + 1u); + EMBK_API_REQUIRE(EMB_API_ID_WORKQ_INIT, attr != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); + EMBK_API_REQUIRE(EMB_API_ID_WORKQ_INIT, out_workq != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, + 3u); + EMBK_API_REQUIRE(EMB_API_ID_WORKQ_INIT, + ((uintptr_t)storage % (uintptr_t)EMB_WORKQ_STORAGE_ALIGN) == 0u, + EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + q = (embk_workq_t *)(void *)storage; +#if CONFIG_EMB_OBJ_LIST + /* storage is reused only through destroy (SPEC-009 §6, KRN-OBJ-007) */ + key = embk_lock_sched(); + if (embk_list_contains(&embk_workq_all, &q->all_node)) { + embk_unlock_sched(key); + EMBK_API_MISUSE(EMB_API_ID_WORKQ_INIT, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); + } + embk_unlock_sched(key); +#endif + embk_obj_init(&q->obj, EMBK_OBJ_WORKQ, 0u, attr->name); + embk_list_init(&q->fifo); + embk_wait_queue_init(&q->done_q, 0u); + q->running = NULL; + q->stopping = 0u; + emb_thread_attr_default(&ta); + ta.name = attr->name; + ta.stack = attr->stack; + ta.stack_size = attr->stack_size; + ta.priority = attr->priority; + st = embk_thread_init_kernel(EMB_API_ID_WORKQ_INIT, &q->thread, &ta, workq_main, q); + if (st != EMB_OK) { + q->obj.state = EMBK_OBJ_UNINIT; + return st; + } + key = embk_lock_sched(); +#if CONFIG_EMB_OBJ_LIST + embk_list_append(&embk_workq_all, &q->all_node); +#endif + out_workq->raw = embk_obj_to_raw(q); + embk_unlock_sched(key); + return EMB_OK; +} + +emb_status_t emb_workq_destroy(emb_workq_t workq) +{ + embk_workq_t *q = workq_from_handle(workq); + embk_thread_t *self; + emb_irq_key_t key; + embk_wait_result_t r; + uintptr_t data; + bool deferred; + EMBK_API_REQUIRE_THREAD(EMB_API_ID_WORKQ_DESTROY); + self = embk_cpu.current; + if (q == NULL) { + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), 0u, EMB_EINVAL); + EMBK_API_MISUSE(EMB_API_ID_WORKQ_DESTROY, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + } + key = embk_lock_object(q); + if (self == &q->thread || !embk_list_is_empty(&q->fifo) || q->running != NULL || + q->stopping != 0u || !embk_wait_queue_is_empty(&q->done_q)) { + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), q, EMB_EBUSY); + embk_unlock_object(q, key); + EMBK_API_MISUSE(EMB_API_ID_WORKQ_DESTROY, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); + } + q->stopping = 1u; + embk_notify_set_locked(&q->thread, EMB_NOTIFY_K_WORK); + /* the join is not a cancellation point: the queue must not be left half destroyed */ + deferred = self->cancel_disable < UINT8_MAX; + if (deferred) { + self->cancel_disable++; /* own field, thread context */ + } + r = embk_block_on(&q->thread.join_q, EMB_WAIT_REASON_JOIN, EMBK_DEADLINE_FOREVER, key, &data); + (void)data; + if (deferred) { + self->cancel_disable--; + } + EMBK_ASSERT(r == EMBK_WAKE_SATISFIED); + (void)r; + embk_thread_destroy_kernel(&q->thread); + key = embk_lock_sched(); +#if CONFIG_EMB_OBJ_LIST + embk_list_remove(&q->all_node); +#endif + q->obj.state = EMBK_OBJ_UNINIT; + embk_unlock_sched(key); + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), q, EMB_OK); + return EMB_OK; +} + +emb_status_t emb_workq_flush(emb_workq_t workq, emb_timeout_t timeout) +{ + embk_workq_t *q = workq_from_handle(workq); + embk_thread_t *self; + embk_work_t marker; + emb_irq_key_t key; + embk_wait_result_t r; + uintptr_t data; + emb_status_t st; + bool must_wait; + emb_tick_t deadline = embk_deadline_from_timeout(timeout); /* fixed at the call */ + EMBK_API_REQUIRE_THREAD(EMB_API_ID_WORKQ_FLUSH); + self = embk_cpu.current; + if (q == NULL) { + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), 0u, EMB_EINVAL); + EMBK_API_MISUSE(EMB_API_ID_WORKQ_FLUSH, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + } + EMBK_API_REQUIRE_CAN_BLOCK(EMB_API_ID_WORKQ_FLUSH, EMB_TIMEOUT_IS_NO_WAIT(timeout) ? 0u : 1u); + key = embk_lock_object(q); + if (self == &q->thread) { + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), q, EMB_EPERM); + embk_unlock_object(q, key); + EMBK_API_MISUSE(EMB_API_ID_WORKQ_FLUSH, EMB_FAULT_API_CONTEXT, EMB_EPERM, 1u); + } + must_wait = false; + if (q->stopping != 0u) { + st = EMB_ESTATE; + } else if (embk_thread_take_cancel_locked(self)) { + st = EMB_ECANCELED; + } else if (embk_list_is_empty(&q->fifo) && q->running == NULL) { + st = EMB_OK; /* idle: nothing queued before the call */ + } else if (EMB_TIMEOUT_IS_NO_WAIT(timeout)) { + st = EMB_ETIMEDOUT; + } else { + st = EMB_OK; + must_wait = true; + } + if (!must_wait) { + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), q, st); + embk_unlock_object(q, key); + return st; + } + embk_list_node_init(&marker.node); + marker.fn = NULL; + marker.queue = q; + marker.tag = EMBK_WORK_TAG; + marker.state = EMB_WORK_PENDING; + marker.flags = EMBK_WORK_MARKER; + embk_list_append(&q->fifo, &marker.node); + embk_notify_set_locked(&q->thread, EMB_NOTIFY_K_WORK); + self->wake_data = (uintptr_t)▮ /* the awaited item until the queue reaches it */ + r = embk_block_on(&q->done_q, EMB_WAIT_REASON_OBJECT, deadline, key, &data); + (void)data; + if (r != EMBK_WAKE_SATISFIED) { + key = embk_lock_object(q); + if (marker.state == EMB_WORK_PENDING) { + embk_list_remove(&marker.node); /* it lives on this stack */ + } + embk_unlock_object(q, key); + } + st = embk_wait_result_status(r); + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), q, st); + return st; +} + +void emb_work_init(emb_work_t *work, emb_work_fn_t fn) +{ + embk_work_t *w = (embk_work_t *)(void *)work; + if (w == NULL) { + return; + } + embk_list_node_init(&w->node); + w->fn = fn; + w->queue = NULL; + w->tag = EMBK_WORK_TAG; + w->state = EMB_WORK_IDLE; + w->flags = 0u; +} + +emb_status_t emb_work_submit(emb_workq_t workq, emb_work_t *work) +{ + embk_workq_t *q = workq_from_handle(workq); + embk_work_t *w = work_of(work); + emb_irq_key_t key; + emb_status_t st = EMB_OK; + EMBK_API_REQUIRE(EMB_API_ID_WORK_SUBMIT, q != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + EMBK_API_REQUIRE(EMB_API_ID_WORK_SUBMIT, w != NULL && w->fn != NULL, EMB_FAULT_API_ARGUMENT, + EMB_EINVAL, 2u); + key = embk_lock_object(q); + if (q->stopping != 0u) { + st = EMB_ESTATE; /* being destroyed */ + } else if (w->state == EMB_WORK_PENDING) { + st = EMB_EEXIST; /* never queued twice (KRN-WQ-005) */ + } else if (w->state == EMB_WORK_RUNNING) { + if (w->queue == q) { + w->flags |= EMBK_WORK_RESUBMITTED; /* queued again when the handler returns */ + } else { + st = EMB_EBUSY; /* an item belongs to one queue at a time */ + } + } else { + w->state = EMB_WORK_PENDING; + w->queue = q; + embk_list_append(&q->fifo, &w->node); + embk_notify_set_locked(&q->thread, EMB_NOTIFY_K_WORK); + } + EMBK_TRACE(EMB_TRACE_WORK_SUBMIT, embk_thread_index(&q->thread), w, st); + embk_sched_reschedule_if_needed(); + embk_unlock_object(q, key); + return st; +} + +emb_status_t emb_work_cancel(emb_work_t *work, emb_work_state_t *out_state) +{ + embk_work_t *w = work_of(work); + emb_irq_key_t key; + emb_work_state_t was; + EMBK_API_REQUIRE(EMB_API_ID_WORK_CANCEL, w != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + key = embk_lock_sched(); + was = w->state; + if (was == EMB_WORK_PENDING) { + unlink_locked(w); + } else if (was == EMB_WORK_RUNNING) { + w->flags &= (uint8_t)~EMBK_WORK_RESUBMITTED; /* the handler completes */ + } else { + /* idle: nothing to cancel */ + } + EMBK_TRACE(EMB_TRACE_WORK_CANCEL, caller_index(), w, was); + embk_unlock_sched(key); + if (out_state != NULL) { + *out_state = was; + } + return EMB_OK; +} + +emb_status_t emb_work_cancel_sync(emb_work_t *work, emb_timeout_t timeout) +{ + embk_work_t *w = work_of(work); + embk_thread_t *self; + embk_workq_t *q; + emb_irq_key_t key; + embk_wait_result_t r; + uintptr_t data; + emb_status_t st; + bool must_wait; + emb_tick_t deadline = embk_deadline_from_timeout(timeout); /* fixed at the call */ + EMBK_API_REQUIRE_THREAD(EMB_API_ID_WORK_CANCEL_SYNC); + self = embk_cpu.current; + if (w == NULL) { + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), 0u, EMB_EINVAL); + EMBK_API_MISUSE(EMB_API_ID_WORK_CANCEL_SYNC, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + } + EMBK_API_REQUIRE_CAN_BLOCK(EMB_API_ID_WORK_CANCEL_SYNC, + EMB_TIMEOUT_IS_NO_WAIT(timeout) ? 0u : 1u); + key = embk_lock_sched(); + q = w->queue; + if (q != NULL && self == &q->thread) { + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), w, EMB_EPERM); + embk_unlock_sched(key); + EMBK_API_MISUSE(EMB_API_ID_WORK_CANCEL_SYNC, EMB_FAULT_API_CONTEXT, EMB_EPERM, 1u); + } + must_wait = false; + if (embk_thread_take_cancel_locked(self)) { + st = EMB_ECANCELED; + } else if (w->state != EMB_WORK_RUNNING) { + if (w->state == EMB_WORK_PENDING) { + unlink_locked(w); + } + st = EMB_OK; + } else { + w->flags &= (uint8_t)~EMBK_WORK_RESUBMITTED; + st = EMB_TIMEOUT_IS_NO_WAIT(timeout) ? EMB_ETIMEDOUT : EMB_OK; + must_wait = st == EMB_OK; + } + if (!must_wait) { + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), w, st); + embk_unlock_sched(key); + return st; + } + self->wake_data = (uintptr_t)w; /* the awaited item until its handler returns */ + r = embk_block_on(&q->done_q, EMB_WAIT_REASON_OBJECT, deadline, key, &data); + (void)data; + st = embk_wait_result_status(r); + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), w, st); + return st; +} + +emb_work_state_t emb_work_state(const emb_work_t *work) +{ + const embk_work_t *w = work_of(work); + return (w == NULL) ? EMB_WORK_IDLE : w->state; +} + +#endif /* CONFIG_EMB_WORKQ */ diff --git a/tests/conformance/CMakeLists.txt b/tests/conformance/CMakeLists.txt index 8ac0373..4c23a47 100644 --- a/tests/conformance/CMakeLists.txt +++ b/tests/conformance/CMakeLists.txt @@ -29,6 +29,9 @@ endif() if(CONFIG_EMB_MSGQ) emb_add_test(test_msgq test_msgq.c) endif() +if(CONFIG_EMB_WORKQ) + emb_add_test(test_workq test_workq.c) +endif() emb_add_test(test_first_execution test_first_execution.c) if(CONFIG_EMB_ARCH_HAS_FPU_LAZY) emb_add_test(test_fpu test_fpu.c) diff --git a/tests/conformance/test_workq.c b/tests/conformance/test_workq.c new file mode 100644 index 0000000..34fbb23 --- /dev/null +++ b/tests/conformance/test_workq.c @@ -0,0 +1,229 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* Work queue conformance (SPEC-006 §4; KRN-WQ-001, 002, 004 to 006, 008 to 010). */ +#include + +#define IRQ_W 2u /* software interrupt slot */ + +static emb_workq_storage_t wq_storage; +EMB_THREAD_STACK(wq_stack, EMB_TEST_STACK_SIZE); +static emb_workq_t wq; + +/* An item that marks its id and, if asked, sleeps or resubmits itself while it runs. */ +typedef struct { + emb_work_t work; + int id; + uint32_t sleep_ticks; + bool resubmit_once; +} item_t; + +static item_t items[3]; + +static void handler(emb_work_t *work) +{ + item_t *it = EMB_CONTAINER_OF(work, item_t, work); + emb_test_mark(it->id); + if (it->resubmit_once) { + it->resubmit_once = false; + EMB_ASSERT_OK(emb_work_submit(wq, work)); /* while running: queued again after */ + } + if (it->sleep_ticks != 0u) { + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(it->sleep_ticks))); + } + emb_test_mark(it->id + 10); +} + +static void make(uint8_t priority) +{ + emb_workq_attr_t attr; + unsigned i; + emb_workq_attr_default(&attr); + attr.name = "wq"; + attr.stack = wq_stack; + attr.stack_size = sizeof(wq_stack); + attr.priority = priority; + EMB_ASSERT_OK(emb_workq_init(&wq_storage, &attr, &wq)); + for (i = 0u; i < 3u; i++) { + emb_work_init(&items[i].work, handler); + items[i].id = (int)i + 1; + items[i].sleep_ticks = 0u; + items[i].resubmit_once = false; + } +} + +EMB_TEST(workq_runs_items_in_fifo_order_on_its_thread) +{ + EMB_TEST_REQ("KRN-WQ-001", "KRN-WQ-002", "KRN-WQ-005", "KRN-WQ-009"); + make(2u); + emb_sched_lock(); /* queue three before the queue's thread can run */ + EMB_ASSERT_OK(emb_work_submit(wq, &items[0].work)); + EMB_ASSERT_OK(emb_work_submit(wq, &items[1].work)); + EMB_ASSERT_STATUS(emb_work_submit(wq, &items[0].work), EMB_EEXIST); /* not queued twice */ + EMB_ASSERT_OK(emb_work_submit(wq, &items[2].work)); + EMB_ASSERT_EQ(emb_work_state(&items[1].work), EMB_WORK_PENDING); + emb_sched_unlock(); /* the higher queue thread drains them, one at a time */ + EMB_ASSERT_MARKS(1, 11, 2, 12, 3, 13); + EMB_ASSERT_EQ(emb_work_state(&items[2].work), EMB_WORK_IDLE); + EMB_ASSERT_OK(emb_workq_destroy(wq)); +} + +EMB_TEST(workq_resubmit_while_running_runs_again) +{ + EMB_TEST_REQ("KRN-WQ-005"); + make(2u); + items[0].resubmit_once = true; + EMB_ASSERT_OK(emb_work_submit(wq, &items[0].work)); + EMB_ASSERT_MARKS(1, 11, 1, 11); /* a second run, never concurrent with the first */ + EMB_ASSERT_OK(emb_workq_destroy(wq)); +} + +EMB_TEST(workq_item_belongs_to_one_queue_at_a_time) +{ + static emb_workq_storage_t other_storage; + EMB_THREAD_STACK(other_stack, EMB_TEST_STACK_SIZE); /* static storage */ + emb_workq_attr_t attr; + emb_workq_t other; + EMB_TEST_REQ("KRN-WQ-005"); + make(2u); + emb_workq_attr_default(&attr); + attr.stack = other_stack; + attr.stack_size = sizeof(other_stack); + attr.priority = 2u; + EMB_ASSERT_OK(emb_workq_init(&other_storage, &attr, &other)); + items[0].sleep_ticks = 2u; + EMB_ASSERT_OK(emb_work_submit(wq, &items[0].work)); /* running on wq: sleeps */ + EMB_ASSERT_STATUS(emb_work_submit(other, &items[0].work), EMB_EBUSY); + EMB_ASSERT_OK(emb_work_cancel_sync(&items[0].work, EMB_WAIT_FOREVER)); + EMB_ASSERT_OK(emb_work_submit(other, &items[0].work)); /* idle again: any queue */ + EMB_ASSERT_OK(emb_workq_flush(other, EMB_WAIT_FOREVER)); + EMB_ASSERT_MARKS(1, 11, 1, 11); + EMB_ASSERT_OK(emb_workq_destroy(other)); + EMB_ASSERT_OK(emb_workq_destroy(wq)); +} + +EMB_TEST(workq_cancel_reports_pending_running_and_idle) +{ + emb_work_state_t st = EMB_WORK_IDLE; + EMB_TEST_REQ("KRN-WQ-004", "KRN-WQ-006"); + make(2u); + emb_sched_lock(); + EMB_ASSERT_OK(emb_work_submit(wq, &items[0].work)); + EMB_ASSERT_OK(emb_work_cancel(&items[0].work, &st)); + EMB_ASSERT_EQ(st, EMB_WORK_PENDING); /* unlinked: it will not run */ + EMB_ASSERT_OK(emb_work_cancel(&items[0].work, &st)); + EMB_ASSERT_EQ(st, EMB_WORK_IDLE); + emb_sched_unlock(); + items[1].sleep_ticks = 5u; + EMB_ASSERT_OK(emb_work_submit(wq, &items[1].work)); /* starts, then sleeps */ + EMB_ASSERT_MARKS(2); + EMB_ASSERT_OK(emb_work_cancel(&items[1].work, &st)); + EMB_ASSERT_EQ(st, EMB_WORK_RUNNING); /* the handler completes */ + EMB_ASSERT_STATUS(emb_work_cancel_sync(&items[1].work, EMB_NO_WAIT), EMB_ETIMEDOUT); + EMB_ASSERT_OK(emb_work_cancel_sync(&items[1].work, EMB_WAIT_FOREVER)); /* until it returns */ + EMB_ASSERT_MARKS(2, 12); + EMB_ASSERT_EQ(emb_work_state(&items[1].work), EMB_WORK_IDLE); + EMB_ASSERT_OK(emb_work_cancel_sync(&items[1].work, EMB_NO_WAIT)); /* idle: at once */ + EMB_ASSERT_OK(emb_workq_destroy(wq)); +} + +EMB_TEST(workq_flush_waits_for_items_queued_before_it) +{ + EMB_TEST_REQ("KRN-WQ-006", "KRN-WAIT-012"); + make(2u); + EMB_ASSERT_OK(emb_workq_flush(wq, EMB_NO_WAIT)); /* idle: at once */ + items[0].sleep_ticks = 3u; + emb_sched_lock(); + EMB_ASSERT_OK(emb_work_submit(wq, &items[0].work)); + EMB_ASSERT_OK(emb_work_submit(wq, &items[1].work)); + emb_sched_unlock(); /* item 1 starts and sleeps */ + EMB_ASSERT_STATUS(emb_workq_flush(wq, EMB_TIMEOUT(EMB_TICKS(1))), EMB_ETIMEDOUT); + EMB_ASSERT_OK(emb_workq_flush(wq, EMB_WAIT_FOREVER)); + EMB_ASSERT_MARKS(1, 11, 2, 12); /* both ran before the flush returned */ + EMB_ASSERT_OK(emb_workq_destroy(wq)); /* the timed-out flush left no marker behind */ +} + +EMB_TEST_ISR(isr_submit) +{ + (void)emb_isr_arg_; + emb_test_mark(100); + EMB_ASSERT_OK(emb_work_submit(wq, &items[0].work)); + EMB_ASSERT_STATUS(emb_work_submit(wq, &items[0].work), EMB_EEXIST); +} + +EMB_TEST(workq_submit_from_isr_runs_at_exit) +{ + EMB_TEST_REQ("KRN-WQ-001", "KRN-IRQ-010"); + make(2u); + emb_test_irq_connect(IRQ_W, isr_submit, NULL); + emb_test_irq_raise(IRQ_W); + emb_test_mark(5); + EMB_ASSERT_MARKS(100, 1, 11, 5); + EMB_ASSERT_OK(emb_workq_destroy(wq)); +} + +static emb_thread_t handler_self; + +static void identity_handler(emb_work_t *work) +{ + (void)work; + handler_self = emb_thread_self(); +} + +EMB_TEST(workq_thread_is_a_kernel_thread) +{ + emb_work_t w; + EMB_TEST_REQ("KRN-WQ-009", "KRN-WQ-010"); + make(2u); + emb_work_init(&w, identity_handler); + EMB_ASSERT_OK(emb_work_submit(wq, &w)); + EMB_ASSERT_TRUE(!EMB_HANDLE_IS_NULL(handler_self)); + EMB_ASSERT_EQ(emb_thread_get_priority(handler_self), 2u); + EMB_ASSERT_MISUSE(emb_thread_cancel(handler_self), EMB_EPERM); /* kernel-owned */ + EMB_ASSERT_MISUSE(emb_notify_set(handler_self, EMB_NOTIFY_BIT(0)), EMB_EPERM); + EMB_ASSERT_STATUS(emb_thread_join(handler_self, EMB_NO_WAIT, NULL), EMB_EPERM); + EMB_ASSERT_OK(emb_workq_destroy(wq)); +} + +EMB_TEST(workq_destroy_refuses_a_busy_queue) +{ + EMB_TEST_REQ("KRN-OBJ-001", "KRN-WQ-010", "API-013"); + make(2u); + items[0].sleep_ticks = 2u; + EMB_ASSERT_OK(emb_work_submit(wq, &items[0].work)); /* running: sleeps */ + EMB_ASSERT_MISUSE(emb_workq_destroy(wq), EMB_EBUSY); + EMB_ASSERT_OK(emb_work_cancel_sync(&items[0].work, EMB_WAIT_FOREVER)); + EMB_ASSERT_MARKS(1, 11); + EMB_ASSERT_OK(emb_workq_destroy(wq)); /* idle: its thread exits and is joined */ + EMB_ASSERT_MISUSE(emb_work_submit(wq, &items[0].work), EMB_EINVAL); /* stale handle */ +} + +#if CONFIG_EMB_OBJ_LIST +EMB_TEST(workq_storage_reuse_requires_destroy) +{ + emb_workq_attr_t attr; + emb_workq_t other; + EMB_TEST_REQ("KRN-OBJ-007", "KRN-OBJ-011"); + make(2u); + emb_workq_attr_default(&attr); + attr.stack = wq_stack; + attr.stack_size = sizeof(wq_stack); + attr.priority = 2u; + EMB_ASSERT_MISUSE(emb_workq_init(&wq_storage, &attr, &other), EMB_EBUSY); + EMB_ASSERT_OK(emb_workq_destroy(wq)); +} +#endif + +#if CONFIG_EMB_SYSTEM_WORKQ +EMB_TEST(workq_system_queue_runs_items) +{ + emb_workq_t sys = emb_workq_system(); + EMB_TEST_REQ("KRN-WQ-008"); + EMB_ASSERT_TRUE(!EMB_HANDLE_IS_NULL(sys)); + emb_work_init(&items[0].work, handler); + items[0].id = 7; + items[0].sleep_ticks = 0u; + items[0].resubmit_once = false; + EMB_ASSERT_OK(emb_work_submit(sys, &items[0].work)); /* a high priority: runs at once */ + EMB_ASSERT_MARKS(7, 17); +} +#endif diff --git a/tests/differential/diff_runner.c b/tests/differential/diff_runner.c index 8fd5cd4..7c748a1 100644 --- a/tests/differential/diff_runner.c +++ b/tests/differential/diff_runner.c @@ -15,6 +15,8 @@ * E=[,fifo][,bind=.] event flags * C= a condition variable (fifo 0 or 1) * Q=[,fifo][,bind=.] a message queue of 32-bit items + * W=, a work queue whose thread the trace calls tid + * H:;... a work item and its handler's operations * I:;... an ISR body (give, resume, nset, start, eset, eclear, * and qsend, qfront, qrecv, qpeek with no wait) * Operations (t: -1 forever, 0 no wait, n ticks): @@ -23,7 +25,8 @@ * slock | sunlock | exit code | start tid | join tid t | bind s tid bit | unbind s | * tdestroy tid | eset e bits | eclear e bits | ewait e mask all clear t | edestroy e | * cwait c m t | csignal c | cbcast c | cdestroy c | qsend q value t | qfront q value | - * qrecv q t | qpeek q | qdestroy q + * qrecv q t | qpeek q | qdestroy q | submit q w | wcancel w | wcsync w t | wflush q t | + * wqdestroy q * A schedule entry fires right after the n-th trace event (or in idle, if that event never * comes): "isr@n" raises ISR i, "tick@n" advances virtual time by one tick. */ @@ -42,6 +45,9 @@ #if CONFIG_EMB_MSGQ #include /* the final ring contents */ #endif +#if CONFIG_EMB_WORKQ +#include /* the queue thread's index */ +#endif #define MAX_THREADS 8u #define MAX_OPS 16u @@ -51,6 +57,8 @@ #define MAX_CONDS 4u #define MAX_MSGQS 4u #define MAX_MSGQ_CAP 8u +#define MAX_WORKQS 2u +#define MAX_WORKS 4u #define MAX_ISRS 4u #define MAX_EVENTS 16384u #define MAX_SCHED 64u @@ -96,7 +104,12 @@ enum op_kind { OP_QFRONT, OP_QRECV, OP_QPEEK, - OP_QDESTROY + OP_QDESTROY, + OP_SUBMIT, + OP_WCANCEL, + OP_WCSYNC, + OP_WFLUSH, + OP_WQDESTROY }; struct op { @@ -170,6 +183,24 @@ struct msgq_desc { #endif }; +struct workq_desc { + long q; + long tid; + uint8_t prio; +#if CONFIG_EMB_WORKQ + emb_workq_storage_t storage; + emb_workq_t handle; +#endif +}; + +struct work_desc { + long w; + struct program body; +#if CONFIG_EMB_WORKQ + emb_work_t work; +#endif +}; + struct mutex_desc { long mid; long protocol; @@ -204,6 +235,13 @@ static struct cond_desc conds[MAX_CONDS]; static unsigned cond_count; static struct msgq_desc msgqs[MAX_MSGQS]; static unsigned msgq_count; +static struct workq_desc workqs[MAX_WORKQS]; +static unsigned workq_count; +static struct work_desc works[MAX_WORKS]; +static unsigned work_count; +#if CONFIG_EMB_WORKQ +EMB_THREAD_STACK(workq_stacks[MAX_WORKQS], STACK_SIZE); +#endif static struct mutex_desc mutexes[MAX_MUTEXES]; static unsigned mutex_count; static struct program isrs[MAX_ISRS]; @@ -386,6 +424,14 @@ static void parse_op(char *text, struct op *op) } else if (strcmp(name, "qpeek") == 0 || strcmp(name, "qdestroy") == 0) { op->kind = (name[1] == 'p') ? OP_QPEEK : OP_QDESTROY; op->a = num(args[0]); + } else if (strcmp(name, "submit") == 0 || strcmp(name, "wcsync") == 0 || + strcmp(name, "wflush") == 0) { + op->kind = (name[0] == 's') ? OP_SUBMIT : (name[1] == 'c') ? OP_WCSYNC : OP_WFLUSH; + op->a = num(args[0]); + op->b = num(args[1]); + } else if (strcmp(name, "wcancel") == 0 || strcmp(name, "wqdestroy") == 0) { + op->kind = (name[1] == 'c') ? OP_WCANCEL : OP_WQDESTROY; + op->a = num(args[0]); } else { die("unknown operation"); } @@ -502,6 +548,25 @@ static void parse_section(char *sec) die("bad message queue attribute"); } } + } else if (kind == 'W') { + struct workq_desc *q; + char *attrs; + if (workq_count >= MAX_WORKQS) { + die("too many work queues"); + } + q = &workqs[workq_count++]; + q->q = num(cut(&p, '=')); + attrs = p; + q->tid = num(cut(&attrs, ',')); + q->prio = (uint8_t)num(attrs); + } else if (kind == 'H') { + struct work_desc *w; + if (work_count >= MAX_WORKS) { + die("too many work items"); + } + w = &works[work_count++]; + w->w = num(cut(&p, ':')); + parse_program(p, &w->body); } else if (kind == 'M') { struct mutex_desc *m; char *attrs; @@ -626,6 +691,32 @@ static struct msgq_desc *msgq_by_qid(long qid) } #endif +#if CONFIG_EMB_WORKQ +static emb_workq_t workq_by_q(long q) +{ + unsigned i; + for (i = 0u; i < workq_count; i++) { + if (workqs[i].q == q) { + return workqs[i].handle; + } + } + die("unknown work queue id"); + return workqs[0].handle; +} + +static emb_work_t *work_by_w(long w) +{ + unsigned i; + for (i = 0u; i < work_count; i++) { + if (works[i].w == w) { + return &works[i].work; + } + } + die("unknown work item id"); + return &works[0].work; +} +#endif + static emb_mutex_t mutex_by_mid(long mid) { unsigned i; @@ -903,6 +994,34 @@ static void run_ops(const struct program *prog, struct thread_desc *self) case OP_QDESTROY: die("message queues are not configured"); break; +#endif +#if CONFIG_EMB_WORKQ + case OP_SUBMIT: + st = emb_work_submit(workq_by_q(op->a), work_by_w(op->b)); + break; + case OP_WCANCEL: { + emb_work_state_t was = EMB_WORK_IDLE; + st = emb_work_cancel(work_by_w(op->a), &was); + data = was; + break; + } + case OP_WCSYNC: + st = emb_work_cancel_sync(work_by_w(op->a), timeout_of(op->b)); + break; + case OP_WFLUSH: + st = emb_workq_flush(workq_by_q(op->a), timeout_of(op->b)); + break; + case OP_WQDESTROY: + st = emb_workq_destroy(workq_by_q(op->a)); + break; +#else + case OP_SUBMIT: + case OP_WCANCEL: + case OP_WCSYNC: + case OP_WFLUSH: + case OP_WQDESTROY: + die("work queues are not configured"); + break; #endif default: die("bad operation kind"); @@ -921,6 +1040,13 @@ static void thread_entry(void *arg) run_ops(&((struct thread_desc *)arg)->prog, (struct thread_desc *)arg); } +#if CONFIG_EMB_WORKQ +static void work_handler(emb_work_t *work) +{ + run_ops(&EMB_CONTAINER_OF(work, struct work_desc, work)->body, NULL); /* results unrecorded */ +} +#endif + static void isr_body(void *arg) { unsigned i = (unsigned)(uintptr_t)arg; @@ -939,7 +1065,7 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) } dumped = 1u; (void)printf("config profile=%s owner_death=%s checked=%d tickless=%d idle_index=254 events=%d " - "conds=%d msgqs=%d\n", + "conds=%d msgqs=%d workqs=%d\n", CONFIG_EMB_PROFILE, #if CONFIG_EMB_MUTEX_OWNER_DEATH_FAULT && CONFIG_EMB_CHECKED "fault", @@ -947,7 +1073,7 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) "release", #endif (int)CONFIG_EMB_CHECKED, (int)CONFIG_EMB_TICKLESS, (int)CONFIG_EMB_EVENT, - (int)CONFIG_EMB_COND, (int)CONFIG_EMB_MSGQ); + (int)CONFIG_EMB_COND, (int)CONFIG_EMB_MSGQ, (int)CONFIG_EMB_WORKQ); n = event_count; for (i = 0u; i < n; i++) { (void)printf("ev %u %u %lu %lu %lu %lu\n", (unsigned)events[i].id, @@ -980,6 +1106,11 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) (unsigned long)emb_event_get(evflags[i].handle)); } #endif +#if CONFIG_EMB_WORKQ + for (i = 0u; i < work_count; i++) { + (void)printf("work %ld %u\n", works[i].w, (unsigned)emb_work_state(&works[i].work)); + } +#endif #if CONFIG_EMB_MSGQ for (i = 0u; i < msgq_count; i++) { const embk_msgq_t *q = (const embk_msgq_t *)(const void *)&msgqs[i].storage; @@ -1096,6 +1227,24 @@ static void construct(void) if (msgq_count != 0u) { die("message queues are not configured"); } +#endif +#if CONFIG_EMB_WORKQ + for (i = 0u; i < workq_count; i++) { + emb_workq_attr_t attr; + emb_workq_attr_default(&attr); + attr.name = "diffwq"; + attr.stack = workq_stacks[i]; + attr.stack_size = sizeof(workq_stacks[i]); + attr.priority = workqs[i].prio; + EMB_CHECK(emb_workq_init(&workqs[i].storage, &attr, &workqs[i].handle)); + } + for (i = 0u; i < work_count; i++) { + emb_work_init(&works[i].work, work_handler); + } +#else + if (workq_count != 0u || work_count != 0u) { + die("work queues are not configured"); + } #endif for (i = 0u; i < mutex_count; i++) { emb_mutex_attr_t attr; @@ -1175,6 +1324,13 @@ int main(int argc, char **argv) (void)printf("thread %ld index %u\n", threads[i].tid, (unsigned)emb_thread_index(threads[i].handle)); } +#if CONFIG_EMB_WORKQ + for (i = 0; (unsigned)i < workq_count; i++) { + const embk_workq_t *q = (const embk_workq_t *)(const void *)&workqs[i].storage; + (void)printf("thread %ld index %u\n", workqs[i].tid, + (unsigned)embk_thread_index(&q->thread)); + } +#endif started = 1u; emb_kernel_start(); } diff --git a/tools/fault/api-ids.json b/tools/fault/api-ids.json index 9a93021..c108e87 100644 --- a/tools/fault/api-ids.json +++ b/tools/fault/api-ids.json @@ -685,6 +685,66 @@ "name": "emb_msgq_unbind_notify", "header": "msgq.h", "state": "active" + }, + { + "id": 115, + "name": "emb_workq_attr_default", + "header": "work.h", + "state": "active" + }, + { + "id": 116, + "name": "emb_workq_init", + "header": "work.h", + "state": "active" + }, + { + "id": 117, + "name": "emb_workq_destroy", + "header": "work.h", + "state": "active" + }, + { + "id": 118, + "name": "emb_workq_flush", + "header": "work.h", + "state": "active" + }, + { + "id": 119, + "name": "emb_workq_system", + "header": "work.h", + "state": "active" + }, + { + "id": 120, + "name": "emb_work_init", + "header": "work.h", + "state": "active" + }, + { + "id": 121, + "name": "emb_work_submit", + "header": "work.h", + "state": "active" + }, + { + "id": 122, + "name": "emb_work_cancel", + "header": "work.h", + "state": "active" + }, + { + "id": 123, + "name": "emb_work_cancel_sync", + "header": "work.h", + "state": "active" + }, + { + "id": 124, + "name": "emb_work_state", + "header": "work.h", + "state": "active" } ] } diff --git a/tools/fault/api-registry.json b/tools/fault/api-registry.json index fdeb0a7..f11d03b 100644 --- a/tools/fault/api-registry.json +++ b/tools/fault/api-registry.json @@ -684,6 +684,66 @@ "name": "emb_msgq_unbind_notify", "header": "msgq.h", "state": "active" + }, + { + "id": 115, + "name": "emb_workq_attr_default", + "header": "work.h", + "state": "active" + }, + { + "id": 116, + "name": "emb_workq_init", + "header": "work.h", + "state": "active" + }, + { + "id": 117, + "name": "emb_workq_destroy", + "header": "work.h", + "state": "active" + }, + { + "id": 118, + "name": "emb_workq_flush", + "header": "work.h", + "state": "active" + }, + { + "id": 119, + "name": "emb_workq_system", + "header": "work.h", + "state": "active" + }, + { + "id": 120, + "name": "emb_work_init", + "header": "work.h", + "state": "active" + }, + { + "id": 121, + "name": "emb_work_submit", + "header": "work.h", + "state": "active" + }, + { + "id": 122, + "name": "emb_work_cancel", + "header": "work.h", + "state": "active" + }, + { + "id": 123, + "name": "emb_work_cancel_sync", + "header": "work.h", + "state": "active" + }, + { + "id": 124, + "name": "emb_work_state", + "header": "work.h", + "state": "active" } ] } diff --git a/tools/model/README.md b/tools/model/README.md index 7ae4ff1..0bab088 100644 --- a/tools/model/README.md +++ b/tools/model/README.md @@ -10,7 +10,7 @@ The model is written so that it can be read next to the specification: `embmodel |---|---| | `embmodel/kernel.py` | State (`Thread`, `WaitQueue`, `Semaphore`, `Mutex`, `Kernel`), the static op types (`Take`, `Give`, `Lock`, `Unlock`, `NotifySet`, `NotifyWait`, `Start`, `Join`, `Sleep`, `Yield`, `SetPrio`, `Suspend`, `Resume`, `Cancel`, `Destroy`, `SchedLock`, `SchedUnlock`, `Exit`), the wait protocol, effective priority with `effective()` and `propagate()`, interrupts and ticks, invariants | | `embmodel/explore.py` | Exhaustive depth-first exploration of every interleaving at section granularity; seeded random walks | -| `tests/scenarios.py` | The scenario catalogue: the SPEC-004 §15 worked example and the wait-protocol scenarios (`ALL`), plus the SPEC-005 §15 mutex scenarios (`MUTEX`): classic inversion, nested chain with timeouts, deadlock in both orders, owner death with and without a waiter, ceiling, waiter priority change, cancellation of a waiter, recursion, two-waiter hand-off; plus the SPEC-006 §11 notification scenarios (`NOTIFY`): set in the window, level semantics, ALL across two sets, one thread bound to two semaphores; plus the SPEC-008 §15 lifecycle scenarios (`JOIN`): join before and after exit, join with timeout, second joiner refused, canceled joiner, start of an inactive thread, exit while owning a mutex with a joiner; plus the binding lifetime scenarios (`BINDING`, ADR-046), event flags (`EVENT`, SPEC-005 §5), condition variables (`COND`, SPEC-005 §6) and message queues (`MSGQ`, SPEC-007 §2, modeled before their implementation per KRN-IPC-022): FIFO order with a full queue, receiver hand-off, send_front and peek, ISR send and receive with `EMB_NO_WAIT`, sender timeout and cancellation, destroy of both sides, the empty-to-nonempty binding | +| `tests/scenarios.py` | The scenario catalogue: the SPEC-004 §15 worked example and the wait-protocol scenarios (`ALL`), plus the SPEC-005 §15 mutex scenarios (`MUTEX`): classic inversion, nested chain with timeouts, deadlock in both orders, owner death with and without a waiter, ceiling, waiter priority change, cancellation of a waiter, recursion, two-waiter hand-off; plus the SPEC-006 §11 notification scenarios (`NOTIFY`): set in the window, level semantics, ALL across two sets, one thread bound to two semaphores; plus the SPEC-008 §15 lifecycle scenarios (`JOIN`): join before and after exit, join with timeout, second joiner refused, canceled joiner, start of an inactive thread, exit while owning a mutex with a joiner; plus the binding lifetime scenarios (`BINDING`, ADR-046), event flags (`EVENT`, SPEC-005 §5), condition variables (`COND`, SPEC-005 §6) and message queues (`MSGQ`, SPEC-007 §2, modeled before their implementation per KRN-IPC-022): FIFO order with a full queue, receiver hand-off, send_front and peek, ISR send and receive with `EMB_NO_WAIT`, sender timeout and cancellation, destroy of both sides, the empty-to-nonempty binding; plus work queues (`WORKQ`, SPEC-006 §4), whose queue thread drains a FIFO of items whose handlers run model operations: FIFO order and idempotent submit, resubmission while running, cancel states, flush with a timeout and while a handler runs, ISR submit, destroy joining the queue thread | | `tests/test_scenarios.py` | Specific interleavings with expected results | | `tests/test_explore.py` | Exhaustive exploration of every scenario plus outcome properties | | `tests/test_random.py` | Random walks over generated scenarios; a Hypothesis-generated scenario/seed campaign when installed | @@ -56,7 +56,7 @@ CTest registers this as `differential_timer_regressions` on applicable native bu ## What is checked -After every action: invariants 1 to 6 of SPEC-004 §5.4 (queue membership matches the wait state; a thread is in the ready structure exactly when runnable and not current; PRIORITY_FIFO queues are ordered; at most one dequeue per wait generation; an armed timeout belongs to a waiting thread or to the open window; the result is set before READY), semaphore unit conservation (gives equal count plus units handed to waiters plus units taken immediately), and the SPEC-005 invariants (every thread's effective priority equals `effective(base, owned)`; an inheriting mutex's owner is at least as high as every waiter on it; a ceiling mutex's owner is at least at the ceiling; an owner is never in its own queue; owned lists and owner fields agree; a free mutex has no waiters), and the message queue invariants (receivers wait only while the ring is empty, senders only while it is full, the ring never exceeds its capacity, and every accepted item is received once, still stored or dropped by destroy). At every terminal state: no runnable thread is left undispatched, no timeout is left armed for a blocked thread, and no blocked thread sits on an undetected inheritance cycle. +After every action: invariants 1 to 6 of SPEC-004 §5.4 (queue membership matches the wait state; a thread is in the ready structure exactly when runnable and not current; PRIORITY_FIFO queues are ordered; at most one dequeue per wait generation; an armed timeout belongs to a waiting thread or to the open window; the result is set before READY), semaphore unit conservation (gives equal count plus units handed to waiters plus units taken immediately), and the SPEC-005 invariants (every thread's effective priority equals `effective(base, owned)`; an inheriting mutex's owner is at least as high as every waiter on it; a ceiling mutex's owner is at least at the ceiling; an owner is never in its own queue; owned lists and owner fields agree; a free mutex has no waiters), and the message queue invariants (receivers wait only while the ring is empty, senders only while it is full, the ring never exceeds its capacity, and every accepted item is received once, still stored or dropped by destroy), and the work queue invariants (an item is queued exactly when it is in exactly one FIFO, running exactly when it is its queue's running item, resubmitted only while running, and every completion waiter awaits a running item or a queued flush marker). At every terminal state: no runnable thread is left undispatched, no timeout is left armed for a blocked thread, and no blocked thread sits on an undetected inheritance cycle. ## Extending diff --git a/tools/model/bridge.py b/tools/model/bridge.py index 1c60a0f..76c8091 100644 --- a/tools/model/bridge.py +++ b/tools/model/bridge.py @@ -31,7 +31,7 @@ from embmodel import (BindNotify, Cancel, CondBroadcast, CondDestroy, CondSignal, CondWait, # noqa: E402 Destroy, EventClear, EventDestroy, EventSet, EventWait, Exit, Give, Join, Lock, ModelError, MsgDestroy, MsgPeek, MsgRecv, - MsgSend, MsgSendFront, + MsgSend, MsgSendFront, Submit, WorkCancel, WorkCancelSync, WorkFlush, WorkqDestroy, NONWAIT_RESULT_OPS, NotifySet, NotifyWait, Policy, Reason, Result, Resume, Scenario, SchedLock, SchedUnlock, SetPrio, Sleep, Start, Suspend, Take, ThreadDestroy, UnbindNotify, Unlock, Yield, build, FOREVER, NO_WAIT) @@ -46,6 +46,7 @@ TR_EVENT_SET, TR_EVENT_WAIT = 31, 32 TR_COND_WAIT, TR_COND_SIGNAL = 33, 34 TR_MSGQ_SEND, TR_MSGQ_RECEIVE = 35, 36 +TR_WORK_WAIT = 40 CTX_THREAD = 0 # emb_context_t EV_ISR_MARK = 1000 # the runner's own record: ISR body i starts EV_INDEX_MARK = 1001 # the runner's record: thread tid now has index i @@ -167,6 +168,16 @@ def encode_op(op) -> str: return f"qpeek {op.q}" if isinstance(op, MsgDestroy): return f"qdestroy {op.q}" + if isinstance(op, Submit): + return f"submit {op.q} {op.w}" + if isinstance(op, WorkCancel): + return f"wcancel {op.w}" + if isinstance(op, WorkCancelSync): + return f"wcsync {op.w} {_t(op.timeout)}" + if isinstance(op, WorkFlush): + return f"wflush {op.q} {_t(op.timeout)}" + if isinstance(op, WorkqDestroy): + return f"wqdestroy {op.q}" raise ValueError(f"unknown op {op!r}") @@ -201,6 +212,10 @@ def encode_scenario(sc: Scenario) -> str: tid, bit = cfg["binding"] attrs.append(f"bind={tid}.{bit}") parts.append(f"Q{qid}=" + ",".join(attrs)) + for q, cfg in sc.workqs.items(): + parts.append(f"W{q}={cfg['tid']},{cfg['prio']}") + for w, cfg in sc.works.items(): + parts.append(f"H{w}:" + ";".join(encode_op(op) for op in cfg.get("body", ()))) for mid, cfg in sc.mutexes.items(): proto = int(cfg.get("protocol", 0)) parts.append(f"M{mid}={proto},{cfg.get('ceiling', 0)},{int(bool(cfg.get('recursive', False)))}") @@ -221,6 +236,7 @@ def __init__(self): self.sems = {} # sid -> count self.evflags = {} # eid -> bits self.msgqs = {} # qid -> stored items, head first + self.works = {} # w -> emb_work_state_t self.end = None # ("done",) or ("fault", class, code) self.events_lost = 0 self.exit_code = 0 @@ -288,6 +304,8 @@ def parse_run(stdout: str, stderr: str = "", exit_code: int = 0) -> Run: r.evflags[int(f[1])] = int(f[2]) elif f[0] == "msgq": r.msgqs[int(f[1])] = tuple(int(x) for x in f[2:]) + elif f[0] == "work": + r.works[int(f[1])] = int(f[2]) elif f[0] == "events_lost": r.events_lost = int(f[1]) elif f[0] == "end": @@ -329,7 +347,7 @@ def thr(idx): elif eid == TR_WAKE: out.append(("wake", thr(a0), KWAKE_TO_RESULT[a1])) elif eid in (TR_SEM_TAKE, TR_NOTIFY_WAIT, TR_MUTEX_LOCK, TR_EVENT_WAIT, TR_COND_WAIT) or \ - (eid in (TR_MSGQ_SEND, TR_MSGQ_RECEIVE) and ctx == CTX_THREAD): # an ISR's call has no wait + (eid in (TR_MSGQ_SEND, TR_MSGQ_RECEIVE, TR_WORK_WAIT) and ctx == CTX_THREAD): # ISR calls never wait st = a1 if eid == TR_NOTIFY_WAIT else a2 st = st - (1 << 64) if st >= (1 << 63) else st # printed as unsigned long res = Result.SATISFIED if st == -7 else STATUS_TO_RESULT.get(st) @@ -585,6 +603,8 @@ def finish(self, k, path): if path and path[-1] == "fault": raise Divergence("the model faulted where the kernel did not") for tid, t in k.threads.items(): + if tid not in self.sc.threads: + continue # a work queue's thread: no program, no handle kres = r.results.get(tid, {}) prog = self.sc.threads[tid][1] for (op_index, result, data) in t.results: @@ -600,7 +620,7 @@ def finish(self, k, path): kres_mapped = Result.TIMEOUT # a completed sleep is EMB_OK in the kernel if kres_mapped != result: raise Divergence(f"thread {tid} op {op_index} {op!r}: model {result.name}, kernel status {kstatus}") - if isinstance(op, (NotifyWait, Join, Lock, EventWait, CondWait, MsgRecv, MsgPeek)) \ + if isinstance(op, (NotifyWait, Join, Lock, EventWait, CondWait, MsgRecv, MsgPeek, WorkCancel)) \ and result == Result.SATISFIED \ and kdata != data: raise Divergence(f"thread {tid} op {op_index} {op!r}: model data {data}, kernel data {kdata}") @@ -623,6 +643,9 @@ def finish(self, k, path): for eid, e in k.events.items(): if eid in r.evflags and not e.destroyed and r.evflags[eid] != e.bits: raise Divergence(f"event flags {eid}: kernel bits {r.evflags[eid]:#x}, model {e.bits:#x}") + for wid, w in k.works.items(): + if r.works.get(wid) != w.state: + raise Divergence(f"work item {wid}: kernel state {r.works.get(wid)}, model {w.state}") for qid, q in k.msgqs.items(): if not q.destroyed and r.msgqs.get(qid) != q.ring: raise Divergence(f"message queue {qid}: kernel items {r.msgqs.get(qid)}, model {q.ring}") @@ -684,6 +707,8 @@ def applicable(sc: Scenario, config: dict) -> tuple: return False, "condition variables are not configured in this build" if sc.msgqs and config.get("msgqs", "0") != "1": return False, "message queues are not configured in this build" + if sc.workqs and config.get("workqs", "0") != "1": + return False, "work queues are not configured in this build" if profile == "tiny": if len(set(prios)) != len(prios): return False, "shared priorities need the base profile" @@ -710,7 +735,7 @@ def adapt(sc: Scenario, config: dict) -> Scenario: def timer_regressions() -> list: - """Exact native placements from M2-01; independent of catalogue RNG consumption.""" + """Exact native placements (M2-01, M2-08); independent of catalogue RNG consumption.""" return [ (Scenario(name="due_sleep_arm", threads={1: (1, (Sleep(1),)), 2: (3, (Sleep(2),))}), [("tick", 0, 10), ("tick", 0, 13)]), @@ -720,6 +745,9 @@ def timer_regressions() -> list: [("tick", 0, 1), ("tick", 0, 22), ("tick", 0, 24), ("isr", 0, 29)]), # This is the related canceled-lock/owner-fault trace, not the historical seed-5 case. (catalogue.cancel_waiter_disinherits(), [("tick", 0, 12), ("tick", 0, 15)]), + # M2-08: a tick inside the flush's first section must not move its deadline, which is + # fixed at the call (SPEC-003 §5.2). Skipped where work queues are not configured. + (catalogue.work_flush_and_timeout(), [("tick", 0, 8), ("tick", 0, 30)]), ] @@ -736,7 +764,7 @@ def main() -> int: selection = ap.add_mutually_exclusive_group() selection.add_argument("--only", help="run the scenarios whose name contains this text") selection.add_argument("--regressions", action="store_true", - help="run the four exact M2-01 timer-boundary inputs instead of a seeded campaign") + help="run the exact timer-boundary inputs instead of a seeded campaign") ap.add_argument("--verbose", "-v", action="store_true") args = ap.parse_args() if args.runs < 1 or not math.isfinite(args.timeout) or args.timeout <= 0 or args.replay_steps < 1: diff --git a/tools/model/check_evidence.py b/tools/model/check_evidence.py index 25411a5..1db1b0c 100644 --- a/tools/model/check_evidence.py +++ b/tools/model/check_evidence.py @@ -5,6 +5,7 @@ import argparse import json from pathlib import Path +import re import subprocess import sys import tempfile @@ -27,10 +28,12 @@ def command(script, *options, expected): return result.stdout with tempfile.TemporaryDirectory(prefix="emblink-evidence-") as temporary: folder = Path(temporary) - command("bridge.py", "--runner", args.runner, "--regressions", "--replay-steps", 1, - "--capture-dir", folder/"captures", expected=1) + out = command("bridge.py", "--runner", args.runner, "--regressions", "--replay-steps", 1, + "--capture-dir", folder/"captures", expected=1) captures = sorted((folder/"captures").glob("*.json")) - assert len(captures) == 4 + runs = re.search(r"bridge: 0/(\d+) runs agree", out) + assert runs and int(runs.group(1)) >= 4, out # every applicable regression failed + assert len(captures) == int(runs.group(1)) selected = None for path in captures: bundle, _sc, _schedule = evidence.read_bundle(path) diff --git a/tools/model/embmodel/__init__.py b/tools/model/embmodel/__init__.py index e68ed37..2a1dd74 100644 --- a/tools/model/embmodel/__init__.py +++ b/tools/model/embmodel/__init__.py @@ -10,6 +10,7 @@ """ from .kernel import ( # noqa: F401 BindNotify, Cancel, CondBroadcast, MsgDestroy, MsgPeek, MsgRecv, MsgSend, MsgSendFront, + Submit, WorkCancel, WorkCancelSync, WorkFlush, WorkqDestroy, K_WORK, WORK_IDLE, WORK_PENDING, WORK_RUNNING, NONWAIT_DATA_OPS, CondDestroy, CondSignal, CondWait, Destroy, EventClear, EventDestroy, EventSet, EventWait, Exit, Give, Join, NONWAIT_RESULT_OPS, ThreadDestroy, UnbindNotify, Kernel, Lock, ModelError, Mutex, NotifySet, NotifyWait, Policy, Protocol, Reason, Result, Resume, Start, SchedLock, SchedUnlock, Scenario, SetPrio, Sleep, Suspend, Take, Thread, diff --git a/tools/model/embmodel/kernel.py b/tools/model/embmodel/kernel.py index 8588331..af95701 100644 --- a/tools/model/embmodel/kernel.py +++ b/tools/model/embmodel/kernel.py @@ -66,6 +66,9 @@ class Protocol(IntEnum): # SPEC-005 §3 EVENT_QID_BASE = 200 # event flags 200.. COND_QID_BASE = 300 # condition variables 300.. MSGQ_QID_BASE = 400 # message queues: receivers 400 + 2i, senders 401 + 2i +WORK_QID_BASE = 500 # work queues: the completion queue of queue q is 500 + q +K_WORK = 1 << 31 # EMB_NOTIFY_K_WORK with 32-bit notification words (SPEC-006 §2.1) +WORK_IDLE, WORK_PENDING, WORK_RUNNING = 0, 1, 2 # emb_work_state_t; PENDING is "queued" class ModelError(AssertionError): @@ -289,6 +292,51 @@ class MsgDestroy: q: int +@dataclass(frozen=True) +class Submit: + """emb_work_submit(q, w): queue an idle item, mark a running one for resubmission (SPEC-006 §4.3).""" + q: int + w: int + + +@dataclass(frozen=True) +class WorkCancel: + """emb_work_cancel(w): unlink a queued item; the result's data is the state it was in.""" + w: int + + +@dataclass(frozen=True) +class WorkCancelSync: + """emb_work_cancel_sync(w, timeout): cancel, then wait until a running handler returns.""" + w: int + timeout: Optional[int] = FOREVER + + +@dataclass(frozen=True) +class WorkFlush: + """emb_workq_flush(q, timeout): wait until every item queued before the call has run.""" + q: int + timeout: Optional[int] = FOREVER + + +@dataclass(frozen=True) +class WorkqDestroy: + """emb_workq_destroy(q): stop an idle queue and wait, not cancelably, for its thread to exit.""" + q: int + + +@dataclass(frozen=True) +class WorkPop: + """The queue thread's section that takes the next FIFO entry (internal).""" + q: int + + +@dataclass(frozen=True) +class WorkDone: + """The queue thread's section after a handler returns (internal).""" + q: int + + def event_satisfying(bits: int, mask: int, all_: bool) -> int: got = bits & mask if all_: @@ -296,9 +344,10 @@ def event_satisfying(bits: int, mask: int, all_: bool) -> int: return got +NONWAIT_RESULT_OPS = NONWAIT_RESULT_OPS + (Submit,) NONWAIT_DATA_OPS = (MsgSendFront, MsgPeek) # record a result and data, never wait ISR_SAFE_OPS = (Give, Resume, NotifySet, Start, EventSet, EventClear, # SPEC-001 §5.2: signal-type operations are `thread isr` - MsgSendFront, MsgPeek, MsgSend, MsgRecv) + MsgSendFront, MsgPeek, MsgSend, MsgRecv, Submit, WorkCancel) ISR_NO_WAIT_ONLY = (MsgSend, MsgRecv) # with EMB_NO_WAIT only (SPEC-007 §1) @@ -339,6 +388,8 @@ class Thread: cond_result: Optional[Result] = None # the condition wait's result, then the final one cond_data: int = 0 # the final result's data (OWNERDEAD_FLAG) msg_value: int = 0 # a blocked sender's item (the kernel keeps its pointer) + wq_item: Optional[int] = None # a queue thread: the item whose handler it runs + work_target: Optional[tuple] = None # a completion wait: ("w", item) or ("m", flusher tid) cancel_deferred: bool = False # cancellation is not delivered (the relock, A41) def key(self): @@ -350,7 +401,7 @@ def key(self): self.notify_clear, tuple(self.owned), self.inactive, self.exit_code, self.joiner, self.collected, self.destroyed, self.cond_phase, None if self.cond_result is None else int(self.cond_result), self.cond_data, - self.cancel_deferred, self.msg_value) + self.cancel_deferred, self.msg_value, self.wq_item, self.work_target) @dataclass @@ -421,6 +472,32 @@ def key(self): self.dropped) +@dataclass +class Work: + wid: int + state: int = WORK_IDLE + queue: Optional[int] = None # the queue it is queued or running on + resubmitted: bool = False + runs: int = 0 # handlers that returned + + def key(self): + return (self.wid, self.state, self.queue, self.resubmitted, self.runs) + + +@dataclass +class Workq: + q: int + tid: int # the queue's thread + done_qid: int + fifo: tuple = () # ("w", item) and ("m", flusher tid) entries, head first + running: Optional[int] = None + stopping: bool = False + destroyed: bool = False + + def key(self): + return (self.q, self.tid, self.fifo, self.running, self.stopping, self.destroyed) + + @dataclass class Mutex: mid: int @@ -446,6 +523,8 @@ class Scenario: events: dict = field(default_factory=dict) # eid -> dict(bits=, policy=, binding=) conds: dict = field(default_factory=dict) # cid -> dict(policy=) msgqs: dict = field(default_factory=dict) # qid -> dict(capacity=, policy=, binding=) + workqs: dict = field(default_factory=dict) # q -> dict(tid=, prio=): the queue thread + works: dict = field(default_factory=dict) # w -> dict(body=ops the handler runs) isrs: tuple = () owner_death: str = "release" # CONFIG_EMB_MUTEX_OWNER_DEATH: "release" | "fault" inactive: tuple = () # tids created INACTIVE (must be started by another thread or an ISR) @@ -467,6 +546,20 @@ def build(sc: Scenario) -> "Kernel": k.queues[sid] = WaitQueue(qid=sid, policy=cfg.get("policy", Policy.PRIORITY_FIFO)) k.sems[sid] = Semaphore(sid=sid, count=cfg.get("count", 0), initial=cfg.get("count", 0), qid=sid, binding=cfg.get("binding")) + for q, cfg in sc.workqs.items(): + tid = cfg["tid"] + if tid in sc.threads: + raise ModelError(f"work queue {q}: thread id {tid} is taken") + k.threads[tid] = Thread(tid=tid, prio=cfg["prio"], base_prio=cfg["prio"]) + k.queues[-(tid + 1)] = WaitQueue(qid=-(tid + 1), policy=Policy.FIFO) + k.queues[-(1000 + tid)] = WaitQueue(qid=-(1000 + tid), policy=Policy.FIFO) + k.queues[WORK_QID_BASE + q] = WaitQueue(qid=WORK_QID_BASE + q, policy=Policy.PRIORITY_FIFO) + k.workqs[q] = Workq(q=q, tid=tid, done_qid=WORK_QID_BASE + q) + k.wq_of_tid[tid] = q + k.programs[tid] = () + for w, cfg in sc.works.items(): + k.works[w] = Work(wid=w) + k.work_bodies[w] = tuple(cfg.get("body", ())) for mq, cfg in sc.msgqs.items(): rq, wq = MSGQ_QID_BASE + 2 * mq, MSGQ_QID_BASE + 2 * mq + 1 k.queues[rq] = WaitQueue(qid=rq, policy=cfg.get("policy", Policy.PRIORITY_FIFO)) @@ -518,6 +611,10 @@ class Kernel: events: dict = field(default_factory=dict) conds: dict = field(default_factory=dict) msgqs: dict = field(default_factory=dict) + workqs: dict = field(default_factory=dict) + works: dict = field(default_factory=dict) + wq_of_tid: dict = field(default_factory=dict) # static: queue thread tid -> queue + work_bodies: dict = field(default_factory=dict) # static: item -> handler ops qid_mutex: dict = field(default_factory=dict) # static map, not part of the key ready: dict = field(default_factory=dict) current: Optional[int] = None @@ -543,6 +640,8 @@ def key(self): tuple(e.key() for _, e in sorted(self.events.items())), tuple(c.key() for _, c in sorted(self.conds.items())), tuple(q.key() for _, q in sorted(self.msgqs.items())), + tuple(q.key() for _, q in sorted(self.workqs.items())), + tuple(w.key() for _, w in sorted(self.works.items())), tuple((p, tuple(v)) for p, v in sorted(self.ready.items()) if v), self.current, self.now, self.irq_nesting, self.sched_lock_depth, self.reschedule_pending, tuple(self.timeouts), self.timer_pending, self.dequeued, @@ -559,6 +658,8 @@ def clone(self) -> "Kernel": k.events = {i: copy.copy(e) for i, e in self.events.items()} k.conds = {i: copy.copy(c) for i, c in self.conds.items()} k.msgqs = {i: copy.copy(q) for i, q in self.msgqs.items()} + k.workqs = {i: copy.copy(q) for i, q in self.workqs.items()} + k.works = {i: copy.copy(w) for i, w in self.works.items()} k.ready = {p: list(v) for p, v in self.ready.items()} k.timeouts = list(self.timeouts) k.trace = [] @@ -794,10 +895,20 @@ def wake_all(self, q: WaitQueue, result: Result, data: int = 0) -> int: def _current_thread(self) -> Thread: return self.threads[self.current] + def _program(self, t: Thread) -> tuple: + """A queue thread's program follows its state: the drain loop, or a handler's body.""" + q = self.wq_of_tid.get(t.tid) + if q is None: + return self.programs[t.tid] + if t.wq_item is None: + return (WorkPop(q), NotifyWait(K_WORK, clear=True, timeout=FOREVER)) + return self.work_bodies[t.wq_item] + (WorkDone(q),) + def _finish(self, t: Thread, result: Result, data: int = 0): - prog = self.programs[t.tid] + prog = self._program(t) op = prog[t.op_index] if t.op_index < len(prog) else Exit() - t.results = t.results + ((t.op_index, result, data),) + if t.tid not in self.wq_of_tid: # a queue thread has no program results + t.results = t.results + ((t.op_index, result, data),) t.op_index += 1 t.pc = 0 t.wait_reason = None @@ -1135,9 +1246,10 @@ def _op_destroy(self, op: Destroy): sem.binding = None # releases the target (ADR-046) # ------------------------------------------------------------------ binding lifetime (M2-03) - def _record(self, t: Thread, result: Result): + def _record(self, t: Thread, result: Result, data: int = 0): """The status of a non-wait operation that the bridge compares.""" - t.results = t.results + ((t.op_index, result, 0),) + if t.tid not in self.wq_of_tid: + t.results = t.results + ((t.op_index, result, data),) def _bound_to(self, tid: int) -> bool: return any(o.binding is not None and o.binding[0] == tid @@ -1219,6 +1331,166 @@ def _op_msg_nowait(self, op) -> tuple: return (Result.SATISFIED, 0) if self._msgq_put(q, op.value, front=True) else (Result.TIMEOUT, 0) return (Result.SATISFIED, q.ring[0]) if q.ring else (Result.TIMEOUT, 0) + # ------------------------------------------------------------------ work queues (SPEC-006 §4, M2-08) + def _op_submit(self, op: Submit) -> Result: + wq, w = self.workqs[op.q], self.works[op.w] + if wq.destroyed: + return Result.STALE + if wq.stopping: + return Result.STATE # being destroyed + if w.state == WORK_PENDING: + return Result.EXIST # idempotent + if w.state == WORK_RUNNING: + if w.queue != op.q: + return Result.BUSY # one queue at a time + w.resubmitted = True # appended again when it returns + return Result.SATISFIED + w.state, w.queue = WORK_PENDING, op.q + wq.fifo = wq.fifo + (("w", op.w),) + self._notify_set(self.threads[wq.tid], K_WORK) + return Result.SATISFIED + + def _op_work_cancel(self, op: WorkCancel) -> int: + w = self.works[op.w] + if w.state == WORK_PENDING: + wq = self.workqs[w.queue] + wq.fifo = tuple(e for e in wq.fifo if e != ("w", op.w)) + w.state, w.queue = WORK_IDLE, None + return WORK_PENDING + if w.state == WORK_RUNNING: + w.resubmitted = False # the handler completes + return WORK_RUNNING + return WORK_IDLE + + def _wake_done(self, wq: Workq, target: tuple): + """Wake every completion waiter for @target, in queue order.""" + dq = self.queues[wq.done_qid] + for tid, _ in list(dq.waiters): + if self.threads[tid].work_target == target: + self.wake(self.threads[tid], Result.SATISFIED, 0, dq, handoff=True) + + def _op_work_pop(self, t: Thread, op: WorkPop): + wq = self.workqs[op.q] + if wq.fifo: + entry, wq.fifo = wq.fifo[0], wq.fifo[1:] + if entry[0] == "m": # a flush marker: never run + self._wake_done(wq, entry) + self.reschedule_if_needed() + return + w = self.works[entry[1]] + w.state = WORK_RUNNING + wq.running = w.wid + t.wq_item, t.op_index, t.pc = w.wid, 0, 0 # the handler's body follows + return + if wq.stopping: + self._exit(t, 0) + return + self._advance(t) # wait for K_WORK + + def _op_work_done(self, t: Thread, op: WorkDone): + wq, w = self.workqs[op.q], self.works[t.wq_item] + if t.owned: + raise ModelError(f"work item {w.wid} returned owning mutexes {t.owned} (SPEC-006 §6)") + wq.running = None + w.runs += 1 + if w.resubmitted: + w.resubmitted = False + w.state = WORK_PENDING + wq.fifo = wq.fifo + (("w", w.wid),) + else: + w.state, w.queue = WORK_IDLE, None + self._wake_done(wq, ("w", w.wid)) + t.wq_item, t.op_index, t.pc = None, 0, 0 + self.reschedule_if_needed() + + def _op_work_cancel_sync(self, t: Thread, op: WorkCancelSync) -> bool: + w = self.works[op.w] + if t.pc == 0 and w.state != WORK_IDLE and self.wq_of_tid.get(t.tid) == w.queue: + self._finish(t, Result.EPERM) # it would wait for itself: misuse + return True + dq = self.queues[self.workqs[w.queue].done_qid] if w.queue is not None else None + + def satisfied(): + return w.state != WORK_RUNNING + + def consume(): + self._op_work_cancel(WorkCancel(op.w)) + return 0 + + def pre_nowait(): + w.resubmitted = False + return None + + def on_enqueue(): + t.work_target = ("w", op.w) + + def finish(t_, result, data=0): + t_.work_target = None + self._finish(t_, result, data) + return self._block_section(t, dq, Reason.OBJECT, op.timeout, satisfied, consume, pre_nowait=pre_nowait, + on_enqueue=on_enqueue, finish=finish) + + def _op_work_flush(self, t: Thread, op: WorkFlush) -> bool: + wq = self.workqs[op.q] + if t.pc == 0: + if wq.destroyed: + self._finish(t, Result.STALE) + return True + if t.tid == wq.tid: + self._finish(t, Result.EPERM) # it would wait for itself: misuse + return True + if wq.stopping: + self._finish(t, Result.STATE) + return True + marker = ("m", t.tid) + + def pre_block(): + wq.fifo = wq.fifo + (marker,) + self._notify_set(self.threads[wq.tid], K_WORK) + return None + + def on_enqueue(): + t.work_target = marker + + def finish(t_, result, data=0): + if result != Result.SATISFIED: + wq.fifo = tuple(e for e in wq.fifo if e != marker) # unlinked on timeout or cancel + t_.work_target = None + self._finish(t_, result, data) + return self._block_section(t, self.queues[wq.done_qid], Reason.OBJECT, op.timeout, + lambda: not wq.fifo and wq.running is None, lambda: 0, + pre_block=pre_block, on_enqueue=on_enqueue, finish=finish) + + def _op_workq_destroy(self, t: Thread, op: WorkqDestroy) -> bool: + wq = self.workqs[op.q] + qt = self.threads[wq.tid] + if t.pc == 0: + if wq.destroyed: + self._finish(t, Result.STALE) + return True + busy = wq.fifo or wq.running is not None or wq.stopping or self.queues[wq.done_qid].waiters + if t.tid == wq.tid or busy: + self._finish(t, Result.EPERM) # misuse: a fault when checked + return True + t.cancel_deferred = True # the join is not cancelable (E6) + + def pre_block(): + wq.stopping = True + self._notify_set(qt, K_WORK) + return None + + def on_enqueue(): + qt.joiner = t.tid + + def finish(t_, result, data=0): + t_.cancel_deferred = False + qt.joiner, qt.collected, qt.destroyed = None, True, True + wq.destroyed = True + self._finish(t_, result, 0) + return self._block_section(t, self.queues[-(1000 + wq.tid)], Reason.JOIN, FOREVER, + lambda: qt.terminated, lambda: 0, pre_block=pre_block, + on_enqueue=on_enqueue, finish=finish) + def _op_msg_destroy(self, op: MsgDestroy): q = self.msgqs[op.q] if q.destroyed: @@ -1385,7 +1657,7 @@ def step_thread(self) -> bool: if self.current is None: return False t = self._current_thread() - prog = self.programs[t.tid] + prog = self._program(t) op = prog[t.op_index] if t.op_index < len(prog) else Exit() if isinstance(op, Take): self._op_take(t, op) @@ -1398,7 +1670,8 @@ def step_thread(self) -> bool: elif isinstance(op, Sleep): self._op_sleep(t, op) elif isinstance(op, NotifyWait): - self._op_notify_wait(t, op) + if self._op_notify_wait(t, op) and t.tid in self.wq_of_tid and t.wq_item is None: + t.op_index = 0 # back to the FIFO elif isinstance(op, NotifySet): self._notify_set(self.threads[op.tid], op.bits) self._advance(t) @@ -1471,10 +1744,30 @@ def step_thread(self) -> bool: res = self._op_bind(t, op) elif isinstance(op, UnbindNotify): res = self._op_unbind(op) + elif isinstance(op, Submit): + res = self._op_submit(op) else: res = self._op_thread_destroy(op) self._record(t, res) self._advance(t) + if isinstance(op, Submit): + self.reschedule_if_needed() + elif isinstance(op, WorkCancel): + self._record(t, Result.SATISFIED, self._op_work_cancel(op)) + self._advance(t) + elif isinstance(op, WorkPop): + self._op_work_pop(t, op) + elif isinstance(op, WorkDone): + self._op_work_done(t, op) + elif isinstance(op, (WorkCancelSync, WorkFlush, WorkqDestroy)): + if isinstance(op, WorkCancelSync): + done = self._op_work_cancel_sync(t, op) + elif isinstance(op, WorkFlush): + done = self._op_work_flush(t, op) + else: + done = self._op_workq_destroy(t, op) + if done: + self.reschedule_if_needed() elif isinstance(op, SchedLock): self.sched_lock_depth += 1 self._advance(t) @@ -1527,6 +1820,10 @@ def fire_isr(self, index: int): self._op_msg_recv(None, op) elif isinstance(op, NONWAIT_DATA_OPS): self._op_msg_nowait(op) + elif isinstance(op, Submit): + self._op_submit(op) + elif isinstance(op, WorkCancel): + self._op_work_cancel(op) self.irq_nesting -= 1 self._p1() @@ -1578,6 +1875,29 @@ def check_invariants(self): for s in self.sems.values(): # ADR-046: no binding outlives its target if s.binding is not None and self.threads[s.binding[0]].destroyed: raise ModelError(f"semaphore {s.sid} is bound to destroyed thread {s.binding[0]}") + for w in self.works.values(): # SPEC-006 §4.3 + places = [(wq.q, e) for wq in self.workqs.values() for e in wq.fifo if e == ("w", w.wid)] + if (w.state == WORK_PENDING) != (len(places) == 1) or len(places) > 1: + raise ModelError(f"work item {w.wid}: state {w.state} but queued {len(places)} times") + if places and places[0][0] != w.queue: + raise ModelError(f"work item {w.wid}: queued on {places[0][0]}, owned by {w.queue}") + running_on = [wq.q for wq in self.workqs.values() if wq.running == w.wid] + if (w.state == WORK_RUNNING) != bool(running_on) or (running_on and running_on != [w.queue]): + raise ModelError(f"work item {w.wid}: state {w.state}, running on {running_on}") + if w.resubmitted and w.state != WORK_RUNNING: + raise ModelError(f"work item {w.wid}: resubmitted while not running") + if w.state == WORK_IDLE and w.queue is not None: + raise ModelError(f"work item {w.wid}: idle but on queue {w.queue}") + for wq in self.workqs.values(): + if wq.running != self.threads[wq.tid].wq_item: + raise ModelError(f"work queue {wq.q}: running {wq.running}, thread runs {self.threads[wq.tid].wq_item}") + for tid, _ in self.queues[wq.done_qid].waiters: + target = self.threads[tid].work_target + ok = (target is not None and + (self.works[target[1]].state == WORK_RUNNING and self.works[target[1]].queue == wq.q + if target[0] == "w" else target in wq.fifo)) + if not ok: + raise ModelError(f"work queue {wq.q}: thread {tid} waits for {target}, which cannot complete") for q in self.msgqs.values(): # SPEC-007 §2.2 if self.queues[q.recv_qid].waiters and q.ring: raise ModelError(f"message queue {q.qid}: receivers wait while {len(q.ring)} items are stored") @@ -1687,4 +2007,5 @@ def outcome(self) -> tuple: ("mutex_owners", tuple((mid, m.owner) for mid, m in sorted(self.mutexes.items()))), ("notify_bits", tuple((tid, t.notify_bits) for tid, t in sorted(self.threads.items()))), ("events", tuple((eid, e.bits) for eid, e in sorted(self.events.items()))), - ("msgqs", tuple((qid, q.ring) for qid, q in sorted(self.msgqs.items())))) + ("msgqs", tuple((qid, q.ring) for qid, q in sorted(self.msgqs.items()))), + ("works", tuple((wid, w.state, w.runs) for wid, w in sorted(self.works.items())))) diff --git a/tools/model/evidence.py b/tools/model/evidence.py index 4f421a3..a860840 100644 --- a/tools/model/evidence.py +++ b/tools/model/evidence.py @@ -27,7 +27,8 @@ bridge.BindNotify, bridge.UnbindNotify, bridge.ThreadDestroy, bridge.EventSet, bridge.EventClear, bridge.EventWait, bridge.EventDestroy, bridge.CondWait, bridge.CondSignal, bridge.CondBroadcast, bridge.CondDestroy, - bridge.MsgSend, bridge.MsgSendFront, bridge.MsgRecv, bridge.MsgPeek, bridge.MsgDestroy)} + bridge.MsgSend, bridge.MsgSendFront, bridge.MsgRecv, bridge.MsgPeek, bridge.MsgDestroy, + bridge.Submit, bridge.WorkCancel, bridge.WorkCancelSync, bridge.WorkFlush, bridge.WorkqDestroy)} def scenario_data(sc): @@ -44,6 +45,9 @@ def program(ops): data["conds"] = list(sc.conds.items()) if sc.msgqs: # and message queues data["msgqs"] = list(sc.msgqs.items()) + if sc.workqs: # and work queues with their items' handlers + data["workqs"] = list(sc.workqs.items()) + data["works"] = [(w, dict(body=program(cfg.get("body", ())))) for w, cfg in sc.works.items()] return data @@ -87,6 +91,8 @@ def objects(items, enum_field=None, enum_type=None): events=objects(raw.get("events", []), "policy", bridge.Policy), conds=objects(raw.get("conds", []), "policy", bridge.Policy), msgqs=objects(raw.get("msgqs", []), "policy", bridge.Policy), + workqs=objects(raw.get("workqs", [])), + works={w: dict(body=program(cfg["body"])) for w, cfg in objects(raw.get("works", [])).items()}, isrs=tuple(program(ops) for ops in raw["isrs"]), inactive=tuple(raw["inactive"]), owner_death=raw["owner_death"], tickless=raw["tickless"]) diff --git a/tools/model/tests/scenarios.py b/tools/model/tests/scenarios.py index f23caf7..8e9bf64 100644 --- a/tools/model/tests/scenarios.py +++ b/tools/model/tests/scenarios.py @@ -604,3 +604,77 @@ def msgq_refill_does_not_signal_binding(): msgq_isr_recv_frees_a_slot, msgq_sender_timeout_and_cancel, msgq_destroy_aborts_both_sides, msgq_binding_on_empty_to_nonempty, msgq_refill_does_not_signal_binding] ALL = ALL + MSGQ + + +# --------------------------------------------------------------------------------------- +# M2-08: work queues (SPEC-006 §4): a queue thread drains a FIFO of items whose handlers run +# model operations; thread 9 is the queue's thread +# --------------------------------------------------------------------------------------- +from embmodel import Submit, WorkCancel, WorkCancelSync, WorkFlush, WorkqDestroy # noqa: E402 + + +def work_fifo_and_idempotent_submit(): + """Items run in submission order on the queue thread; a second submit of a queued item is EEXIST.""" + return Scenario(name="work_fifo_and_idempotent_submit", + threads={1: (2, (Submit(0, 0), Submit(0, 0), Submit(0, 1), Take(0), Take(1)))}, + sems={0: dict(count=0), 1: dict(count=0)}, + workqs={0: dict(tid=9, prio=1)}, + works={0: dict(body=(Give(0),)), 1: dict(body=(Give(1),))}) + + +def work_resubmit_while_running(): + """A submit while the handler runs is not lost: the item runs again after it returns.""" + return Scenario(name="work_resubmit_while_running", + threads={1: (1, (Submit(0, 0), Submit(0, 0), Take(0), Take(0)))}, + sems={0: dict(count=0)}, + workqs={0: dict(tid=9, prio=2)}, + works={0: dict(body=(Sleep(1), Give(0)))}) + + +def work_cancel_states(): + """cancel reports PENDING for a queued item, IDLE after, and RUNNING for a running handler; + cancel_sync then waits for that handler to return.""" + return Scenario(name="work_cancel_states", + threads={1: (2, (Submit(0, 0), WorkCancel(0), WorkCancel(0), Submit(0, 0), Sleep(1), + WorkCancel(0), WorkCancelSync(0)))}, + workqs={0: dict(tid=9, prio=1)}, + works={0: dict(body=(Sleep(3),))}) + + +def work_flush_and_timeout(): + """A flush returns once everything queued before it ran; a timed-out flush leaves no marker.""" + return Scenario(name="work_flush_and_timeout", + threads={1: (2, (Submit(0, 0), WorkFlush(0, 1), Submit(0, 1), WorkFlush(0)))}, + workqs={0: dict(tid=9, prio=1)}, + works={0: dict(body=(Sleep(2),)), 1: dict(body=())}) + + +def work_isr_submit(): + """An ISR submits at any point: the item runs exactly once.""" + return Scenario(name="work_isr_submit", + threads={1: (2, (Take(0, 4),))}, + sems={0: dict(count=0)}, + workqs={0: dict(tid=9, prio=1)}, + works={0: dict(body=(Give(0),))}, + isrs=((Submit(0, 0),),)) + + +def workq_destroy_joins_its_thread(): + """Destroy of an idle queue stops its thread and returns after the thread exited.""" + return Scenario(name="workq_destroy_joins_its_thread", + threads={1: (2, (Submit(0, 0), WorkFlush(0), WorkqDestroy(0), Submit(0, 0)))}, + workqs={0: dict(tid=9, prio=1)}, + works={0: dict(body=())}) + + +def work_flush_waits_for_running_handler(): + """A flush while a handler runs and nothing else is queued waits for that handler.""" + return Scenario(name="work_flush_waits_for_running_handler", + threads={1: (1, (Submit(0, 0), WorkFlush(0), WorkCancel(0)))}, + workqs={0: dict(tid=9, prio=2)}, + works={0: dict(body=(Sleep(2),))}) + + +WORKQ = [work_fifo_and_idempotent_submit, work_resubmit_while_running, work_cancel_states, work_flush_and_timeout, + work_isr_submit, workq_destroy_joins_its_thread, work_flush_waits_for_running_handler] +ALL = ALL + WORKQ diff --git a/tools/model/tests/test_explore.py b/tools/model/tests/test_explore.py index b724606..c65bc6e 100644 --- a/tools/model/tests/test_explore.py +++ b/tools/model/tests/test_explore.py @@ -443,3 +443,66 @@ def test_refill_from_a_waiting_sender_does_not_signal(self): self.assertEqual([(r[1], r[2]) for r in d[2]], [(Result.SATISFIED, 1), (Result.SATISFIED, 1), (Result.TIMEOUT, 0)]) self.assertEqual(q[0], (2,)) + + +class WorkQueues(unittest.TestCase): + """M2-08 (SPEC-006 §4): serial FIFO execution, idempotent submit, resubmission while running, + cancel states, cancel_sync and flush completion, and destroy joining the queue thread. The + item-state invariants are checked in every explored state.""" + + @staticmethod + def outcomes(make): + return [(dict(dict(o)["threads"]), {w: (s, n) for w, s, n in dict(o)["works"]}) + for o in explore(make()).outcomes] + + def test_every_workq_scenario_holds_invariants(self): + for make in S.WORKQ: + sc = make() + with self.subTest(scenario=sc.name): + self.assertGreater(explore(sc).terminals, 0) + + def test_fifo_and_idempotent_submit(self): + for d, w in self.outcomes(S.work_fifo_and_idempotent_submit): + self.assertEqual([r[1] for r in d[1]], [Result.SATISFIED, Result.EXIST] + [Result.SATISFIED] * 3) + self.assertEqual(w, {0: (0, 1), 1: (0, 1)}) + + def test_resubmit_while_running_runs_again(self): + for d, w in self.outcomes(S.work_resubmit_while_running): + self.assertEqual([r[1] for r in d[1]], [Result.SATISFIED] * 4) + self.assertEqual(w[0], (0, 2)) + + def test_cancel_reports_each_state(self): + seen = set() + for d, w in self.outcomes(S.work_cancel_states): + res = {r[0]: (r[1], r[2]) for r in d[1]} + self.assertEqual(res[1], (Result.SATISFIED, 1)) # PENDING: unlinked + self.assertEqual(res[2], (Result.SATISFIED, 0)) # IDLE + seen.add(res[5][1]) # RUNNING, or PENDING if T1 woke first + self.assertEqual(res[6][0], Result.SATISFIED) # cancel_sync: done or waited for it + self.assertEqual(w[0], (0, 1 if res[5][1] == 2 else 0)) + self.assertEqual(seen, {1, 2}) + + def test_flush_waits_and_a_timed_out_flush_leaves_no_marker(self): + for d, w in self.outcomes(S.work_flush_and_timeout): + res = {r[0]: r[1] for r in d[1]} + self.assertEqual(res[1], Result.TIMEOUT) + self.assertEqual(res[3], Result.SATISFIED) + self.assertEqual(w, {0: (0, 1), 1: (0, 1)}) + + def test_isr_submit_runs_once(self): + seen = set() + for d, w in self.outcomes(S.work_isr_submit): + seen.add(d[1][0][1]) + self.assertEqual(w[0][1], 1) + self.assertIn(Result.SATISFIED, seen) + + def test_destroy_joins_the_queue_thread(self): + for o in explore(S.workq_destroy_joins_its_thread()).outcomes: + d = dict(dict(o)["threads"]) + self.assertEqual([r[1] for r in d[1]], [Result.SATISFIED] * 3 + [Result.STALE]) + + def test_flush_waits_for_a_running_handler(self): + for d, w in self.outcomes(S.work_flush_waits_for_running_handler): + self.assertEqual([(r[1], r[2]) for r in d[1]], + [(Result.SATISFIED, 0), (Result.SATISFIED, 0), (Result.SATISFIED, 0)]) # idle after + self.assertEqual(w[0], (0, 1)) diff --git a/tools/toolchain/native_abi.c b/tools/toolchain/native_abi.c index 3281c4e..2582d34 100644 --- a/tools/toolchain/native_abi.c +++ b/tools/toolchain/native_abi.c @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -139,6 +140,15 @@ void emb_native_abi_probe(void) FIELD(cond_obj, embk_cond_t, obj); FIELD(cond_waiters, embk_cond_t, waiters); #endif +#if CONFIG_EMB_WORKQ + TYPE(work, embk_work_t); + FIELD(work_node, embk_work_t, node); + FIELD(work_state, embk_work_t, state); + TYPE(workq, embk_workq_t); + FIELD(workq_thread, embk_workq_t, thread); + FIELD(workq_fifo, embk_workq_t, fifo); + FIELD(workq_done_q, embk_workq_t, done_q); +#endif #if CONFIG_EMB_MSGQ TYPE(msgq, embk_msgq_t); FIELD(msgq_obj, embk_msgq_t, obj); From 31ef6807ed3f273d90099a95b210963505954c0d Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 21:17:57 +0100 Subject: [PATCH 19/30] fix(riscv): keep the reset code out of the -ffunction-sections namespace The reset entry lived in .text.reset, which both RISC-V linker scripts place first. With -ffunction-sections, any C function named reset is emitted into the same section and can be linked in front of embn_rv_reset, so the hart starts in that function with sp = 0. A static helper named reset in a conformance test did exactly that on rv32-virt: the image faulted on its second instruction. The section is now .text.embn_rv_reset, a name only the reset entry can produce. Signed-off-by: teo1747 --- arch/riscv/rv_switch.S | 2 +- boards/espressif/esp32c3_devkitm1/esp32c3.ld | 2 +- boards/qemu/riscv32_virt/riscv32_virt.ld | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/arch/riscv/rv_switch.S b/arch/riscv/rv_switch.S index 74a4ce3..156748a 100644 --- a/arch/riscv/rv_switch.S +++ b/arch/riscv/rv_switch.S @@ -173,7 +173,7 @@ embn_rv_vectors: /* Reset (QEMU virt with -bios none starts here in machine mode; the ESP32-C3's boot ROM * jumps here once it has loaded the image): interrupts off, the * stack, .bss cleared, then C. Everything is loaded in RAM, so .data needs no copy. */ - .section .text.reset, "ax" + .section .text.embn_rv_reset, "ax" /* the one function this name can belong to */ .globl embn_rv_reset .align 2 embn_rv_reset: diff --git a/boards/espressif/esp32c3_devkitm1/esp32c3.ld b/boards/espressif/esp32c3_devkitm1/esp32c3.ld index 8ccbe86..2912e6c 100644 --- a/boards/espressif/esp32c3_devkitm1/esp32c3.ld +++ b/boards/espressif/esp32c3_devkitm1/esp32c3.ld @@ -23,7 +23,7 @@ SECTIONS { .text : { KEEP(*(.text.embn_rv_vectors)) - KEEP(*(.text.reset)) + KEEP(*(.text.embn_rv_reset)) *(.text .text.*) } > IRAM diff --git a/boards/qemu/riscv32_virt/riscv32_virt.ld b/boards/qemu/riscv32_virt/riscv32_virt.ld index 3691ce4..e38a9cb 100644 --- a/boards/qemu/riscv32_virt/riscv32_virt.ld +++ b/boards/qemu/riscv32_virt/riscv32_virt.ld @@ -17,7 +17,7 @@ MEMORY SECTIONS { .text : { - KEEP(*(.text.reset)) + KEEP(*(.text.embn_rv_reset)) *(.text .text.*) } > RAM From 922f6c78760f2309ad8555f10db770843e833cca Mon Sep 17 00:00:00 2001 From: teo1747 Date: Tue, 27 Oct 2026 21:17:57 +0100 Subject: [PATCH 20/30] feat(timer): software timers in their own deadline list (M2-09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Software timers (SPEC-003 §7, KRN-TIM-025) under CONFIG_EMB_TIMER, in include/emb/timer.h and kernel/time/timer.c, for the list scheduler. - Armed timers sit in their own deadline list (ADR-047). The expiry pass merges it with the thread timeouts: the earlier head first, a thread first on equal deadlines, and each timer at most once per pass (a timer armed already due during a pass waits for the next). The thread path is unchanged; tickless programming takes the earlier head. - A periodic timer re-arms from its previous deadline before the callback runs. Whole missed periods are skipped in closed form, one division, and counted as overruns (A56). - Callbacks run on a named work queue through an embedded work item. An expiry that finds the callback still queued, or running with its rerun already due, coalesces and counts an overrun. With EMB_TIMER_ISR_CONTEXT a callback runs in the expiry interrupt with the timeout lock released. A thread-context timer without a queue is ENOTSUP, so the system queue stays unlinked unless named (A55). - A named queue cannot be destroyed while a timer refers to it. - stop reports IDLE, PENDING or RUNNING; stop_sync waits for a running work-queue callback and is misuse from that callback. - destroy refuses an armed, queued or running timer; restart of a never-started timer is ESTATE (A54, A58). API IDs 125-136, trace IDs 41-43 (A59), type tag 0x58. workq.c exports its locked submit, cancel and cancel_sync paths for timers. The reference model gains timers (A60): ISR-context callbacks run model operations in the expiry pass, work-queue callbacks go through the embedded item, and the model checks that a periodic deadline stays on its first expiry's grid. A scenario horizon stops time from advancing while a thread could run once a periodic timer has run long enough, as the runner does. The TIMER group covers one-shot and periodic ISR timers, equal deadlines, a work-queue callback, stop and stop_sync of a running callback and overruns. The runner and bridge carry R sections and tstart, tstop and tsync. Timer starts, stops and expiries and handler starts and ends are now canonical events, so the bridge pins otherwise silent operations to their place in the kernel's trace. tests/conformance/test_timer.c has 7 cases; the native tickless one checks the closed-form catch-up by holding the timer interrupt off for nine ticks. Signed-off-by: teo1747 --- CHANGELOG.md | 4 + docs/STATUS.md | 5 +- docs/architecture/06-decision-records.md | 12 + docs/engineering/m2-checklist.md | 2 +- docs/engineering/resource-bounds.json | 31 +- docs/engineering/resource-bounds.md | 9 +- docs/requirements/KRN-TIM.md | 32 +- docs/specs/AMENDMENTS-M2.md | 7 + .../SPEC-003-time-timeouts-and-timers.md | 6 +- docs/user/feature-status.md | 2 +- docs/user/tasks.md | 25 ++ include/emb/api_ids.h | 12 + include/emb/emb.h | 3 + include/emb/timer.h | 288 ++++++++++++ include/emb/trace.h | 9 +- kernel/CMakeLists.txt | 3 + kernel/Kconfig | 13 + kernel/include/embk/kernel.h | 1 + kernel/include/embk/time.h | 9 + kernel/include/embk/timer.h | 47 ++ kernel/include/embk/work.h | 14 + kernel/object/storage_probe.c | 4 + kernel/time/time.c | 116 ++++- kernel/time/timer.c | 418 ++++++++++++++++++ kernel/work/workq.c | 99 +++-- tests/conformance/CMakeLists.txt | 3 + tests/conformance/test_timer.c | 246 +++++++++++ tests/differential/diff_runner.c | 127 +++++- tools/fault/api-ids.json | 72 +++ tools/fault/api-registry.json | 72 +++ tools/model/README.md | 4 +- tools/model/bridge.py | 51 ++- tools/model/embmodel/__init__.py | 1 + tools/model/embmodel/kernel.py | 209 ++++++++- tools/model/evidence.py | 11 +- tools/model/tests/scenarios.py | 61 +++ tools/model/tests/test_explore.py | 66 ++- tools/toolchain/native_abi.c | 7 + 38 files changed, 2001 insertions(+), 100 deletions(-) create mode 100644 include/emb/timer.h create mode 100644 kernel/include/embk/timer.h create mode 100644 kernel/time/timer.c create mode 100644 tests/conformance/test_timer.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 32eaa49..18a630b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ User-visible changes are recorded here. There are no released versions or releas ### Added +- Software timers (`CONFIG_EMB_TIMER`, `include/emb/timer.h`, M2-09): one-shot and periodic timers whose callbacks run on a named work queue or, with `EMB_TIMER_ISR_CONTEXT`, in the expiry interrupt. Periodic timers re-arm from the previous deadline, skip missed periods in closed form and count them with coalesced callbacks as overruns; `stop` reports the callback's state and `stop_sync` waits for it. Timers have their own deadline list, merged with thread timeouts at expiry (ADR-047). The reference model, the differential bridge and a conformance suite cover them; they require the list scheduler, so the tiny profile has none in this milestone. + +- The RISC-V reset code is in its own `.text.embn_rv_reset` section, so an application function named `reset` built with `-ffunction-sections` can no longer take the reset vector's place. + - Work queues (`CONFIG_EMB_WORKQ`, `include/emb/work.h`, M2-08): a kernel thread per queue runs submitted items one at a time in FIFO order. Submit is O(1) and ISR-safe, idempotent while queued and kept while running; cancel reports the state, `cancel_sync` and `flush` wait for completion, and destroy joins the queue's thread. The system work queue (`CONFIG_EMB_SYSTEM_WORKQ`) is linked only into images that reference `emb_workq_system()`. The reference model, the differential bridge and a conformance suite cover them; they require the list scheduler, so the tiny profile has none in this milestone. - Message queues (`CONFIG_EMB_MSGQ`, `include/emb/msgq.h`, M2-07): fixed-size items copied by value through a caller-provided ring declared with `EMB_MSGQ_RING()`, with direct hand-off to a waiting receiver, slot hand-off from a waiting sender, `send_front`, `peek`, `count` and `free`, `EMB_NO_WAIT` calls from interrupts, timeouts and absolute deadlines, cancellation, destroy with ABORT_WAITERS, and a receive-side notification binding. Items are bounded by `CONFIG_EMB_MSGQ_MAX_ITEM` (64 bytes, 16 on tiny, where `CONFIG_EMB_IPC_SMALL` keeps 8-bit counts). The reference model, the differential bridge and a conformance suite cover them; they are off in the tiny `footprint_t1` reference configuration. diff --git a/docs/STATUS.md b/docs/STATUS.md index dfd4bf2..58f06d0 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,6 +12,7 @@ This page is the authoritative inventory of implementation and available evidenc | Waiting and time | Shared wait protocol, sleep, deadlines, cancellation, suspend overlay, ordered timeouts | Native virtual time exercises periodic and tickless modes; embedded ports currently reject tickless | | Synchronization | Semaphores, mutexes with inheritance, ceiling, recursion and owner-death policy; event flags with ANY/ALL/CLEAR waits and binding (M2-05); condition variables with the mandatory relock (M2-06) | Conformance and native differential bridge; inheritance and deadlock detection have a configured depth limit | | Communication | Copy-by-value message queues: direct hand-off to a waiting receiver, slot hand-off from a waiting sender, send to front, peek, `EMB_NO_WAIT` calls from ISRs and a receive-side binding (M2-07) | Conformance and native differential bridge; no pipes, buffer pools or zero-copy ownership queues; the masked copy time is bounded by configuration, not measured on hardware | +| Software timers | One-shot and drift-free periodic timers in their own deadline list (ADR-047), with callbacks on a work queue or in interrupt context, closed-form catch-up and overrun counts, stop states and `stop_sync` (M2-09) | Conformance and native differential bridge; list scheduler only (not tiny); arming overhead and jitter not measured on hardware | | Deferred work | Work queues on a kernel thread with FIFO execution, idempotent and ISR-safe submit, resubmission while running, cancel and synchronous cancel, flush, destroy joining the thread, and a system queue linked only when referenced (M2-08, ADR-048) | Conformance and native differential bridge; list scheduler only (not tiny); no delayed work, timers or statistics | | Notifications | Per-thread bits and one semaphore-to-thread binding, with unbind; a bound thread cannot be destroyed (ADR-046) | Binding takes a one-bit mask; other objects' bindings arrive with those objects; restart generations need the isolated profile | | Objects | Caller-owned generated storage and POINTER handles; per-type active-object lists for semaphores and mutexes (`CONFIG_EMB_OBJ_LIST`) used by init | No INDEXED or TABLE handles, capability rights, partition restart, system freeze, `emb_obj_foreach()` or debug-descriptor export | @@ -21,11 +22,11 @@ This page is the authoritative inventory of implementation and available evidenc | Observability | M1 trace sink and fault reporting | CTF rings/metadata, deferred log decoder, retained crash records, debug descriptor and worst-case monitors remain planned | | Documentation | Sphinx/MyST manuals and native tutorials, Doxygen/Breathe C API, Sphinx Needs design catalog, JSON/CSV downloads and annotation audit | Strict local HTML build, complete extracted function annotations, source-preservation and catalog-integrity checks; merged `37887c9` Pages deployment and served evidence-workflow page independently observed successful | | Repository process | Issue forms, PR checklist, changelog, release/M2 work checklists, stable CI merge checks, authored-commit DCO checker and a main ruleset policy | Local syntax and DCO/gate failure checks; GitHub's effective main rules report PRs, resolved conversations, the three strict CI checks and deletion/non-fast-forward protection; no remote rejection experiment claimed | -| Semantic model | UP scheduler/wait/mutex/notification/lifecycle scenarios; message queues, modeled before their implementation (KRN-IPC-022), and work queues; exhaustive section-level exploration and randomized walks | Scenario-scoped exploration, not a proof of all C execution, arbitrary programs, SMP or hardware protection | +| Semantic model | UP scheduler/wait/mutex/notification/lifecycle scenarios; message queues, modeled before their implementation (KRN-IPC-022), work queues and software timers; exhaustive section-level exploration and randomized walks | Scenario-scoped exploration, not a proof of all C execution, arbitrary programs, SMP or hardware protection | ## Specified but unimplemented -Barriers, software timers, delayed work, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. +Barriers, delayed work, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. An API or path named in a design or a requirement's verification field may be a planned deliverable. Check this inventory and the filesystem before treating it as available. diff --git a/docs/architecture/06-decision-records.md b/docs/architecture/06-decision-records.md index 9e440bd..624101f 100644 --- a/docs/architecture/06-decision-records.md +++ b/docs/architecture/06-decision-records.md @@ -575,3 +575,15 @@ Each record: context, decision, alternatives, consequences, status. **Proposed** **Consequences.** M1 images and traces are unchanged, and the footprint of an image that does not use work queues does not grow. An application that references the system queue from a translation unit also gets its stack; one that only creates its own queues does not. Work queue destroy blocks until the thread has switched out, so it is thread-only. KRN-WQ-008's "exists from kernel start" holds for every image that can name the queue. The table scheduler has no work queues in M2 (A53). **Status.** Implemented for review, 2026-10-27, as part of M2-08; awaiting project-owner review. + +## ADR-047 Software timers keep their own deadline list + +**Context.** SPEC-003 §5 gives every thread one intrusive timeout node in one deadline list, and its expiry loop wakes the thread that owns each node. Software timers need the same ordered expiry, but a timer node is not a thread: sharing the list means tagging every node with its kind, testing the tag on the thread timeout path that M1 measured and traced, and letting timers reorder thread timeouts at equal deadlines. A periodic timer also re-arms during the expiry pass and its callback may start timers there, which a single loop over "every node due at now" could expire again and again in one pass. + +**Decision.** Timers have their own deadline list, protected by the same timeout lock. The expiry pass takes the earlier of the two heads; on equal deadlines the thread timeout goes first, and each list keeps arming order. Each timer records the pass that armed it, and a timer armed during the current pass (a callback restarting a timer with a deadline already past) waits for the next pass. The next-deadline computation for tickless programming takes the earlier head. A periodic timer re-arms from its previous deadline before its callback, skipping whole missed periods in closed form. + +**Alternatives.** One list with a kind tag costs a byte or a pointer test per node on every thread timeout and changes M1's insertion order. A timing wheel is a drop-in alternative behind the same interface where measurements show the list walk on the critical path (KRN-TIM-021); nothing shows that yet. Bounding a pass by a counter instead of the pass marker would cut off due nodes arbitrarily. + +**Consequences.** With `CONFIG_EMB_TIMER=n` the thread timeout path is the M1 code. Arming a timer is O(armed timers); stopping is O(1). A timer restarted already due from its own ISR-context callback expires one pass later: at the next tick in periodic mode, at once by the reprogrammed compare in tickless mode. The table scheduler's singly linked list has no timer list, and timers are off with it in M2. + +**Status.** Implemented for review, 2026-10-27, as part of M2-09; awaiting project-owner review. diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index bdbd48f..5c12bf3 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -79,7 +79,7 @@ The `footprint_t1` reference build was at the R-003 T2 limit of 64 bytes of kern - [x] **Condition variables:** model atomic mutex release/enqueue, no stored signal, and mandatory mutex reacquisition even after timeout/cancel; test broadcasts, priority inheritance, recursive-mutex restrictions and relock response-time implications. Follow SPEC-005 and KRN-SYNC-030. - [x] **Message queues:** start with the copy-by-value subset; validate item-size/capacity arithmetic and aligned caller storage, direct receiver/slot hand-off, front send, no-wait ISR paths, absolute deadlines, cancellation/destruction and readiness recheck. Bound bytes copied while masked. Follow SPEC-007/KRN-IPC; ownership queues depend on a later pool gate and must not be advertised early. - [x] **Work queues:** define callback thread/priority and serialized execution; model submit/resubmit states and cancel/flush including callback self-wait misuse, delayed scheduling and teardown. Follow SPEC-006/KRN-WQ; expose only implemented configurations. -- [ ] **Software timers:** use the work queue for default callbacks; model absolute periodic rearm, missed-period policy and checked overflow arithmetic, cancel/destroy while callbacks are queued/running, and any explicitly supported ISR callback mode. Follow SPEC-003/KRN-TIM; do not silently enable embedded tickless before its port contract is implemented. +- [x] **Software timers:** use the work queue for default callbacks; model absolute periodic rearm, missed-period policy and checked overflow arithmetic, cancel/destroy while callbacks are queued/running, and any explicitly supported ISR callback mode. Follow SPEC-003/KRN-TIM; do not silently enable embedded tickless before its port contract is implemented. For each new public API: complete annotations and parameter/lifetime docs, generated storage checks, native checked/release/tiny applicability, boundary misuse, model agreement, emulator evidence for supported ports and a finite user example. An unsupported configuration must be rejected or explicitly excluded; it is not verified by a skipped test. diff --git a/docs/engineering/resource-bounds.json b/docs/engineering/resource-bounds.json index 1c25119..d8577b7 100644 --- a/docs/engineering/resource-bounds.json +++ b/docs/engineering/resource-bounds.json @@ -95,7 +95,7 @@ }, { "id": "active_objects", - "input": "active objects of one type (semaphores, mutexes, event flags, condition variables, message queues, work queues)", + "input": "active objects of one type (semaphores, mutexes, event flags, condition variables, message queues, work queues, timers)", "bound": "not limited by the kernel: the objects the application initializes", "limit": "application", "enforcement": "application", @@ -113,6 +113,16 @@ "profiles": "a byte loop under the object lock on every profile", "evidence": "SPEC-007 \u00a72.2, KRN-IPC-011; tests/conformance/test_msgq.c" }, + { + "id": "armed_timers", + "input": "armed software timers", + "bound": "not limited by the kernel: the timers the application starts", + "limit": "application", + "enforcement": "application", + "masked": true, + "profiles": "list scheduler only; insertion walks from the tail of the timer list (ADR-047), removal O(1); an expiry pass handles each due timer once", + "evidence": "SPEC-003 \u00a77, ADR-047; tests/conformance/test_timer.c" + }, { "id": "stack", "input": "bytes of one thread's stack", @@ -370,6 +380,25 @@ "emb_work_cancel_sync": [ "waiters", "timeouts" + ], + "emb_timer_init": [ + "active_objects" + ], + "emb_timer_start": [ + "armed_timers" + ], + "emb_timer_start_at": [ + "armed_timers" + ], + "emb_timer_start_periodic": [ + "armed_timers" + ], + "emb_timer_restart": [ + "armed_timers" + ], + "emb_timer_stop_sync": [ + "waiters", + "timeouts" ] } } diff --git a/docs/engineering/resource-bounds.md b/docs/engineering/resource-bounds.md index 27b7f41..c701797 100644 --- a/docs/engineering/resource-bounds.md +++ b/docs/engineering/resource-bounds.md @@ -15,8 +15,9 @@ Every cost input named by a public function's `@time` annotation, with how it is | **batch_wake**: waiters woken at once (destroy with EMB_OBJ_ABORT_WAITERS) | the waiters of the destroyed object | as for waiters | structural | yes | one section, one reschedule at the end | SPEC-004 §6.7; KRN-OBJ-001 | | **thread_ids**: diagnostic identity scan at thread init | 254 IDs on list profiles, CONFIG_EMB_PRIORITY_COUNT - 1 on tiny | fixed per profile; EMB_ENOMEM beyond it | admission | yes | bitmap scan from the lowest ID | ADR-043; m2-02-thread-identity.md | | **live_threads**: threads on the all-threads list | at most 254 (the ID space) | CONFIG_EMB_THREAD_LIST; the ID admission | admission | yes | base only (no list on tiny); walked by thread init and emb_thread_foreach | SPEC-008 §12; ADR-045 | -| **active_objects**: active objects of one type (semaphores, mutexes, event flags, condition variables, message queues, work queues) | not limited by the kernel: the objects the application initializes | application | application | yes | with CONFIG_EMB_OBJ_LIST only (not on tiny); walked by init | ADR-045; m2-02-object-lifecycle.md | +| **active_objects**: active objects of one type (semaphores, mutexes, event flags, condition variables, message queues, work queues, timers) | not limited by the kernel: the objects the application initializes | application | application | yes | with CONFIG_EMB_OBJ_LIST only (not on tiny); walked by init | ADR-045; m2-02-object-lifecycle.md | | **item_copy**: bytes copied for one message queue operation | at most two items (a receive that refills from a waiting sender), each item_size bytes | CONFIG_EMB_MSGQ_MAX_ITEM (default 64, tiny 16); init refuses a larger item_size | configuration | yes | a byte loop under the object lock on every profile | SPEC-007 §2.2, KRN-IPC-011; tests/conformance/test_msgq.c | +| **armed_timers**: armed software timers | not limited by the kernel: the timers the application starts | application | application | yes | list scheduler only; insertion walks from the tail of the timer list (ADR-047), removal O(1); an expiry pass handles each due timer once | SPEC-003 §7, ADR-047; tests/conformance/test_timer.c | | **stack**: bytes of one thread's stack | the stack size the application supplied | application | application | no | the high-water scan exists only with CONFIG_EMB_STACK_STATS; init fills the stack in checked and statistics builds | SPEC-008 §10; KRN-MEM-009 | | **init_table**: entries of the initialization table | the EMB_*_DEFINE objects linked into the image | application, fixed at link time | application | no | prekernel only | KRN-OBJ-009 | | **callbacks**: application callbacks the kernel calls (fault hook, trace sink, foreach visitor) | not bounded by the kernel | application | application | yes | the fault hook and the trace sink can run with interrupts masked; the foreach visitor runs under the scheduler lock | API-017; SPEC-015 | @@ -70,6 +71,12 @@ Every cost input named by a public function's `@time` annotation, with how it is | `emb_thread_sleep()` | O(timeouts) arm | timeouts | | `emb_thread_sleep_until()` | O(timeouts) arm | timeouts | | `emb_thread_stack_info()` | O(stack) scan with statistics on | stack | +| `emb_timer_init()` | O(1); O(active timers) list lookup with CONFIG_EMB_OBJ_LIST | active_objects | +| `emb_timer_restart()` | O(armed timers) insertion | armed_timers | +| `emb_timer_start()` | O(armed timers) insertion | armed_timers | +| `emb_timer_start_at()` | O(armed timers) insertion | armed_timers | +| `emb_timer_start_periodic()` | O(armed timers) insertion | armed_timers | +| `emb_timer_stop_sync()` | O(1); O(waiters + timeouts) finite wait enqueue | waiters, timeouts | | `emb_work_cancel_sync()` | O(1); O(waiters + timeouts) finite wait enqueue | waiters, timeouts | | `emb_work_submit()` | O(1), plus the queue thread's wake | timeouts, reschedule | | `emb_workq_destroy()` | O(1) plus the queue thread's exit | timeouts, reschedule | diff --git a/docs/requirements/KRN-TIM.md b/docs/requirements/KRN-TIM.md index 0a9c91c..7f43ee4 100644 --- a/docs/requirements/KRN-TIM.md +++ b/docs/requirements/KRN-TIM.md @@ -41,8 +41,8 @@ KRN-TIM-001 to 007 originate in the v0.1 specification (§7.5); KRN-TIM-008 to 0 ### KRN-TIM-005 Defined callback-context semantics **Statement.** One-shot and periodic software timers shall have defined callback-context semantics. **Rationale.** Callback context confusion is a leading cause of RTOS bugs. -**Status.** Accepted (v0.1) -**Verification.** Test: callback context observed with `emb_context()` for default and `ISR_CONTEXT` timers. +**Status.** Accepted (v0.1); implemented 2026-10-27 (M2-09). +**Verification.** Test: `timer_isr_context_one_shot_and_periodic_without_drift` observes `EMB_CONTEXT_ISR`, `timer_callback_runs_on_its_work_queue` observes `EMB_CONTEXT_THREAD`. **Trace.** v0.1 §7.5; SPEC-003 §7.3 ### KRN-TIM-006 Bounded timer operations @@ -97,22 +97,22 @@ KRN-TIM-001 to 007 originate in the v0.1 specification (§7.5); KRN-TIM-008 to 0 ### KRN-TIM-013 Callbacks on a work queue by default **Statement.** Software timer callbacks shall execute by default on a work queue in thread context. **Rationale.** Interrupt-context callbacks are dangerous for general code (ADR-019). -**Status.** Accepted (03 §4.3) -**Verification.** Test: default timer callback observes `EMB_CONTEXT_THREAD` and runs on the configured queue's thread. +**Status.** Accepted (03 §4.3); implemented 2026-10-27 (M2-09). The queue is named explicitly (A55). +**Verification.** Test: `timer_callback_runs_on_its_work_queue`, `timer_init_needs_a_queue_or_isr_context`; model scenario `timer_work_queue_callback`. **Trace.** 03 §4.3; SPEC-003 §7.3 ### KRN-TIM-014 ISR-context callbacks are opt-in and bounded **Statement.** A timer may be created with `ISR_CONTEXT` only when its callback is declared ISR-safe; such callbacks shall be bounded and shall not block. **Rationale.** A few callbacks need interrupt-level latency; the rest must not pay for it or risk it. -**Status.** Accepted (03 §4.3) -**Verification.** Test: a blocking call from an `ISR_CONTEXT` callback faults in checked builds. +**Status.** Accepted (03 §4.3); implemented 2026-10-27 (M2-09). +**Verification.** Test: `timer_isr_context_one_shot_and_periodic_without_drift`; a blocking call from the callback is refused by the common interrupt-context checks (inspection; no fault-forking case runs in interrupt context). **Trace.** 03 §4.3; SPEC-003 §7.3 ### KRN-TIM-015 Periodic re-arm without drift **Statement.** One-shot and periodic timers shall be supported; a periodic timer's next expiry shall be computed from the previous expiry, not from callback completion. **Rationale.** Re-arming from completion accumulates callback latency into the period. -**Status.** Accepted (03 §4.3) -**Verification.** Test: a periodic timer's expiry instants are exact multiples of the period from the first expiry over 10 000 periods on native. +**Status.** Accepted (03 §4.3); implemented 2026-10-27 (M2-09). +**Verification.** Test: `timer_isr_context_one_shot_and_periodic_without_drift` (exact spacing), `timer_missed_periods_are_skipped_in_closed_form` (native); model invariant that a periodic deadline stays on its first-expiry grid. The 10 000-period run is not done. **Trace.** 03 §4.3; SPEC-003 §7.4 ### KRN-TIM-016 No 64-bit atomics for time @@ -181,29 +181,29 @@ KRN-TIM-001 to 007 originate in the v0.1 specification (§7.5); KRN-TIM-008 to 0 ### KRN-TIM-025 Software timer API **Statement.** The kernel shall provide `emb_timer_init`, `emb_timer_destroy`, `emb_timer_start`, `emb_timer_start_at`, `emb_timer_start_periodic`, `emb_timer_stop`, `emb_timer_stop_sync`, `emb_timer_restart`, `emb_timer_is_running`, `emb_timer_remaining`, and `emb_timer_get_counts` with the context classes and bounds of SPEC-003 §7.2. Start, stop, restart, and the queries shall be ISR-safe; `stop_sync` shall be thread-only and blocking with a timeout. **Rationale.** Timers are driven from ISRs as often as from threads; teardown needs a synchronous stop. -**Status.** Accepted 2026-10-07 -**Verification.** Test: API conformance including each function from ISR context where allowed. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-09). +**Verification.** Test: the `test_timer` suite on every list-scheduler target; ISR use of start and stop in the model scenarios. **Trace.** SPEC-003 §7.2 ### KRN-TIM-026 Expiry processing **Statement.** At expiry, a work-queue timer shall submit its embedded work item to its configured queue in O(1) without calling application code; an `ISR_CONTEXT` timer shall call its callback directly in the expiry path. Expiry shall never run a work-queue callback in interrupt context. **Rationale.** KRN-TIM-013 and 014 at the mechanism level; the expiry path must stay bounded regardless of callback behavior. -**Status.** Accepted 2026-10-07 -**Verification.** Test: callback context tests; Analysis: expiry path `@time` and stack usage. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-09). +**Verification.** Test: callback context tests; model scenarios `timer_equal_deadlines_thread_first` and the differential bridge's ordering of expiries; Analysis: one pass per interrupt (ADR-047). **Trace.** SPEC-003 §7.3, §7.4 ### KRN-TIM-027 Periodic re-arm and overrun accounting **Statement.** A periodic timer shall be re-armed from its previous deadline before its callback is run; whole periods already past at expiry shall be skipped and counted as overruns; an expiry whose work item is still queued from the previous expiry shall coalesce and count as an overrun. Expiry and overrun counts shall be readable and a trace point shall record each overrun. **Rationale.** No drift (KRN-TIM-015), no unbounded backlog, and visibility when the system cannot keep up. -**Status.** Accepted 2026-10-07 -**Verification.** Test: overload and sleep-past-several-periods scenarios on native; counts and trace match the model. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-09). +**Verification.** Test: `timer_missed_periods_are_skipped_in_closed_form`, `timer_expiries_during_a_long_callback_are_overruns`; model scenario `timer_periodic_overrun_on_long_callback`; counts compared by the differential bridge. **Trace.** SPEC-003 §7.4 ### KRN-TIM-028 Timer lifecycle **Statement.** Destroying a timer whose callback is pending or running shall be misuse: a kernel fault in checked builds and `EMB_EBUSY` in release builds. `emb_timer_stop` shall report whether the callback was idle, pending, or running; `emb_timer_stop_sync` from the timer's own callback shall be misuse. **Rationale.** Use-after-destroy in a work queue is otherwise undetectable. -**Status.** Accepted 2026-10-07 -**Verification.** Test: misuse suite for timers in both build kinds. +**Status.** Accepted 2026-10-07; implemented 2026-10-27 (M2-09). +**Verification.** Test: `timer_restart_remaining_and_stop_states`, `timer_stop_sync_waits_for_a_running_callback`; model scenario `timer_stop_running_callback_and_stop_sync`. **Trace.** SPEC-003 §7.5; KRN-OBJ-001 ### KRN-TIM-029 Time slicing quantum as a deadline diff --git a/docs/specs/AMENDMENTS-M2.md b/docs/specs/AMENDMENTS-M2.md index f7a6f91..c118398 100644 --- a/docs/specs/AMENDMENTS-M2.md +++ b/docs/specs/AMENDMENTS-M2.md @@ -29,3 +29,10 @@ Corrections found while preparing the M2 primitives, where an accepted specifica | A51 | SPEC-006 §5, SPEC-008 §5 | A queue's thread is a kernel thread (ADR-048): thread operations on its handle are refused, notifications to it are `EMB_FAULT_API_OWNER` misuse, and `emb_thread_exit()` from a handler is a context fault. | `kernel/thread/thread.c`, `kernel/notify/notify.c` | | A52 | SPEC-006 §7, SPEC-015 | The trace records are `EMB_TRACE_WORK_SUBMIT`, `EMB_TRACE_WORK_RUN` (handler start and end, from which a decoder derives latency and duration), `EMB_TRACE_WORK_CANCEL`, and `EMB_TRACE_WORK_WAIT`, which carries the status of every outcome of `cancel_sync`, `flush` and destroy (A19). Statistics are not implemented. | `include/emb/trace.h` | | A53 | SPEC-006 §8, §11, §12 decision 10 | Work queues require the list scheduler in M2: `CONFIG_EMB_WORKQ` depends on `!CONFIG_EMB_SCHED_TABLE`. The reference model covers them after all, since the M2 checklist asks for submit and resubmit states, cancel, flush and teardown to be modeled: a queue thread drains a FIFO of items whose handlers run model operations. | `kernel/Kconfig`, `tools/model/embmodel/kernel.py` | +| A54 | SPEC-003 §7.1, §7.2 | `EMB_TIMER_PERIODIC` is removed: a timer is periodic only when started with `emb_timer_start_periodic()`. The attributes' only flag is `EMB_TIMER_ISR_CONTEXT`; `emb_timer_attr_default()` and `emb_timer_state_t` (`EMB_TIMER_IDLE`, `EMB_TIMER_PENDING`, `EMB_TIMER_RUNNING`) are defined. `emb_timer_restart()` of a never-started timer returns `EMB_ESTATE`. | `include/emb/timer.h` | +| A55 | SPEC-003 §7.1, §7.3, KRN-TIM-013 | A thread-context timer names its work queue; a null queue means `EMB_ENOTSUP`, not the system queue, so that the system queue stays unlinked unless referenced (ADR-048): pass `emb_workq_system()` to use it. A queue named by a timer cannot be destroyed. | `kernel/time/timer.c`, `kernel/work/workq.c` | +| A56 | SPEC-003 §7.4, §16, KRN-TIM-027 | Missed periods are skipped in closed form with one division. An expiry coalesces, and counts an overrun, when the callback is still queued or is running with its rerun already due. Counts saturate. | `kernel/time/timer.c` | +| A57 | SPEC-003 §5.3, §7.4 | Timers keep their own deadline list (ADR-047): on equal deadlines a thread timeout expires before a timer, each timer at most once per pass, and one armed already due during a pass expires in the next. | `kernel/time/time.c` | +| A58 | SPEC-003 §7.2 | Start forms are O(armed timers); `stop` is O(1). `emb_timer_stop_sync()` waits only for a work-queue callback; an ISR-context callback cannot be running under a thread, so it returns at once. | `include/emb/timer.h` | +| A59 | SPEC-003 §7.4, SPEC-015 | Trace records `EMB_TRACE_TIMER_EXPIRE` (with the periods skipped), `EMB_TRACE_TIMER_START` and `EMB_TRACE_TIMER_STOP` (with the state found); `stop_sync`'s outcome is the `EMB_TRACE_WORK_WAIT` record. There is no separate overrun event: coalesced callbacks show as an expiry without a submit. | `include/emb/trace.h` | +| A60 | SPEC-003 §14, §15 | The reference model covers timers (ISR-context callbacks running model operations, work-queue callbacks through the embedded work item); the differential bridge follows timer starts, stops, expiries and handler runs as events. An exploration horizon stops time from advancing while a thread could run once a periodic timer has run long enough, as the runner does. The tiny profile has no timers in M2 (maintainer decision, 2026-10-27). | `tools/model/embmodel/kernel.py` | diff --git a/docs/specs/SPEC-003-time-timeouts-and-timers.md b/docs/specs/SPEC-003-time-timeouts-and-timers.md index 2652f24..b8a8d08 100644 --- a/docs/specs/SPEC-003-time-timeouts-and-timers.md +++ b/docs/specs/SPEC-003-time-timeouts-and-timers.md @@ -179,8 +179,8 @@ typedef struct emb_timer_attr { const char *name; emb_timer_fn_t fn; /* required */ void *arg; - emb_workq_t workq; /* EMB_HANDLE_NULL = system work queue */ - uint8_t flags; /* EMB_TIMER_PERIODIC | EMB_TIMER_ISR_CONTEXT */ + emb_workq_t workq; /* required unless ISR_CONTEXT, e.g. emb_workq_system() (A55) */ + uint8_t flags; /* EMB_TIMER_ISR_CONTEXT; periodic only via start_periodic (A54) */ } emb_timer_attr_t; ``` @@ -300,4 +300,4 @@ The timeout structure, the expiry ordering, periodic re-arm with overrun countin ## 16. Pending timer implementation constraints -Software timers remain unimplemented. Periodic catch-up must compute missed periods with checked arithmetic rather than loop once per missed period; the §7.4 loop expresses semantics, not an acceptable unbounded ISR algorithm. State the maximum number of due callbacks and any deferred batch policy, preserving expiry order. Arming-overhead compensation must define clock resolution, calibration uncertainty, and already-due deadlines; no accuracy guarantee exists until measured on hardware. `stop_sync` and delayed-work cancellation need tests for pending, running, resubmitted, and timed-out callbacks. +Implemented in M2-09 (A54 to A60, ADR-047): catch-up is one division, at most one expiry per timer per pass, and the measured arming overhead below remains open. The original constraints: periodic catch-up must compute missed periods with checked arithmetic rather than loop once per missed period; the §7.4 loop expresses semantics, not an acceptable unbounded ISR algorithm. State the maximum number of due callbacks and any deferred batch policy, preserving expiry order. Arming-overhead compensation must define clock resolution, calibration uncertainty, and already-due deadlines; no accuracy guarantee exists until measured on hardware. `stop_sync` and delayed-work cancellation need tests for pending, running, resubmitted, and timed-out callbacks. diff --git a/docs/user/feature-status.md b/docs/user/feature-status.md index 3bd2612..07a51eb 100644 --- a/docs/user/feature-status.md +++ b/docs/user/feature-status.md @@ -7,7 +7,7 @@ This guide describes the current 0.2 development implementation. The authoritati | Start and coordinate threads | Static storage, priorities, start, sleep, join/detach, cooperative cancellation | Isolated partitions, budgets, restart, SMP | | Protect or signal work | Mutexes, semaphores, per-thread notifications, event flags, condition variables | Barriers | | Transfer payloads | Copy-by-value message queues; application-owned data with an explicit synchronization protocol | Pipes, buffer pools, zero-copy ownership queues | -| Run periodic work | A thread using absolute sleep deadlines; work queues and the system work queue for deferred work (not with the tiny profile) | Software timers and delayed work | +| Run periodic work | A thread using absolute sleep deadlines; one-shot and drift-free periodic software timers; work queues and the system work queue for deferred work (not with the tiny profile) | Delayed work items, timers on the tiny profile | | Wait on several readiness sources | Semaphore, event flag and message queue bindings, with unbind, plus notification bits and rechecking; a bound thread cannot be destroyed | Pipe and pool bindings, restart-safe target lifetime | | Diagnose failures | State/priority queries, optional stack estimates, synchronous trace and fault hooks | Retained crash formats, CTF rings, full host tooling | | Develop without a board | Native Linux/POSIX virtual time and conformance suite | Physical-board timing, peripheral drivers and HIL evidence | diff --git a/docs/user/tasks.md b/docs/user/tasks.md index bc5d51e..6fcd2e3 100644 --- a/docs/user/tasks.md +++ b/docs/user/tasks.md @@ -124,3 +124,28 @@ emb_work_init(&job.work, process); ``` An item is never queued twice and never runs concurrently with itself; submitting it while its handler runs queues it once more for after the handler returns. `emb_work_cancel()` reports whether the item was pending (now removed), running (it completes) or idle. Before reusing an item's storage, call `emb_work_cancel_sync()`, which also waits for a running handler. `emb_workq_flush()` waits until everything queued before the call has run. Calling either from the queue's own handlers would wait forever and is misuse, as is returning from a handler with a mutex held. The queue's thread belongs to the kernel: thread calls on it are refused and it cannot be notified directly. `emb_workq_destroy()` needs an idle queue and returns after the thread has exited. Referencing `emb_workq_system()` links in a shared system queue at a high priority; keep its handlers short. + +## Periodic work with a software timer + +A software timer (`CONFIG_EMB_TIMER`, like work queues not available with the tiny profile in this milestone) calls a function after a delay, once or periodically. By default the callback runs on a work queue you name, in thread context; with `EMB_TIMER_ISR_CONTEXT` it runs in the timer interrupt and must be short and ISR-safe: + +```c +static emb_timer_storage_t blink_storage; +static emb_timer_t blink; + +static void toggle(emb_timer_t timer, void *arg) +{ + (void)timer; + (void)arg; + /* thread context on the system work queue */ +} + +emb_timer_attr_t attr; +emb_timer_attr_default(&attr); +attr.fn = toggle; +attr.workq = emb_workq_system(); /* or a queue of your own */ +EMB_CHECK(emb_timer_init(&blink_storage, &attr, &blink)); +EMB_CHECK(emb_timer_start_periodic(blink, EMB_MS(100), EMB_MS(500))); +``` + +A periodic timer re-arms from its previous deadline, so expiry k falls at the first delay plus k periods however late the callbacks run. Periods missed entirely, for example while interrupts were masked, are skipped and counted, and so is an expiry that finds the previous callback still waiting; read both with `emb_timer_get_counts()`. `emb_timer_stop()` reports whether the callback was idle, pending or running, and is how a callback ends its own periodic timer. Before destroying a timer, call `emb_timer_stop_sync()`, which also waits for a running callback. A queue that a timer names cannot be destroyed until the timer is. diff --git a/include/emb/api_ids.h b/include/emb/api_ids.h index 1cf52e0..955e809 100644 --- a/include/emb/api_ids.h +++ b/include/emb/api_ids.h @@ -128,5 +128,17 @@ #define EMB_API_ID_WORK_CANCEL 0x007Au #define EMB_API_ID_WORK_CANCEL_SYNC 0x007Bu #define EMB_API_ID_WORK_STATE 0x007Cu +#define EMB_API_ID_TIMER_ATTR_DEFAULT 0x007Du +#define EMB_API_ID_TIMER_INIT 0x007Eu +#define EMB_API_ID_TIMER_DESTROY 0x007Fu +#define EMB_API_ID_TIMER_START 0x0080u +#define EMB_API_ID_TIMER_START_AT 0x0081u +#define EMB_API_ID_TIMER_START_PERIODIC 0x0082u +#define EMB_API_ID_TIMER_RESTART 0x0083u +#define EMB_API_ID_TIMER_STOP 0x0084u +#define EMB_API_ID_TIMER_STOP_SYNC 0x0085u +#define EMB_API_ID_TIMER_IS_RUNNING 0x0086u +#define EMB_API_ID_TIMER_REMAINING 0x0087u +#define EMB_API_ID_TIMER_GET_COUNTS 0x0088u #endif /* EMB_API_IDS_H */ diff --git a/include/emb/emb.h b/include/emb/emb.h index becc325..438135a 100644 --- a/include/emb/emb.h +++ b/include/emb/emb.h @@ -27,6 +27,9 @@ #include #include #include +#if CONFIG_EMB_TIMER +#include +#endif #include #include #include diff --git a/include/emb/timer.h b/include/emb/timer.h new file mode 100644 index 0000000..470a466 --- /dev/null +++ b/include/emb/timer.h @@ -0,0 +1,288 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* Software timers (SPEC-003 §7): one-shot and drift-free periodic callbacks. */ +#ifndef EMB_TIMER_H +#define EMB_TIMER_H + +#include +#include +#include +#if CONFIG_EMB_WORKQ +#include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +EMB_DECLARE_HANDLE(emb_timer_t); + +/* Defined by the generated . */ +typedef union emb_timer_storage emb_timer_storage_t; + +typedef void (*emb_timer_fn_t)(emb_timer_t timer, void *arg); + +/* What emb_timer_stop() found: nothing, an armed deadline or a queued callback, or a + * callback that is running. */ +typedef uint8_t emb_timer_state_t; +#define EMB_TIMER_IDLE ((emb_timer_state_t)0u) +#define EMB_TIMER_PENDING ((emb_timer_state_t)1u) +#define EMB_TIMER_RUNNING ((emb_timer_state_t)2u) + +/* The callback runs in the expiry interrupt instead of on a work queue (SPEC-003 §7.3). */ +#define EMB_TIMER_ISR_CONTEXT ((uint8_t)0x01u) + +typedef struct emb_timer_attr { + const char *name; + emb_timer_fn_t fn; /* required */ + void *arg; +#if CONFIG_EMB_WORKQ + emb_workq_t workq; /* where the callback runs, e.g. emb_workq_system(); unused with + EMB_TIMER_ISR_CONTEXT */ +#endif + uint8_t flags; /* EMB_TIMER_ISR_CONTEXT */ + uint8_t reserved_[3]; /* explicit padding */ +} emb_timer_attr_t; + +/** + * emb_timer_attr_default() - Fill timer attributes with defaults. + * @out_attr: Receives name, callback and argument NULL, no queue and flags 0, which init + * refuses until a callback and a queue or EMB_TIMER_ISR_CONTEXT are set; NULL + * is a no-op. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_TIMER + * @req API-020 KRN-TIM-025 + * @since 0.3 + * @stable no + */ +void emb_timer_attr_default(emb_timer_attr_t *out_attr); + +/** + * emb_timer_init() - Construct a stopped timer in caller-provided storage. + * @storage: Aligned caller-owned storage, never initialized or destroyed; retain it until + * successful destruction. + * @attr: Required attributes, copied. Keep name storage alive. + * @out_timer: Required output handle; cleared before argument validation. + * + * @ctx prekernel thread + * @blocks no + * @time O(1); O(active timers) list lookup with CONFIG_EMB_OBJ_LIST + * @owns borrows:storage + * @config CONFIG_EMB_TIMER + * @req API-021 KRN-TIM-025 KRN-TIM-005 KRN-TIM-013 KRN-OBJ-003 KRN-OBJ-007 + * @since 0.3 + * @stable no + * + * A timer without EMB_TIMER_ISR_CONTEXT names the work queue its callback runs on; the + * queue cannot be destroyed while the timer exists. + * + * Return: EMB_OK; EMB_EINVAL for bad storage, output or flags, a missing callback or a bad + * queue handle (misuse); EMB_ENOTSUP for a thread-context timer without a queue or + * without CONFIG_EMB_WORKQ; EMB_ENOMEM when the queue is named by 255 timers; + * EMB_EBUSY when @storage still holds a timer that was not destroyed (misuse). + */ +emb_status_t emb_timer_init(emb_timer_storage_t *storage, const emb_timer_attr_t *attr, + emb_timer_t *out_timer); + +/** + * emb_timer_destroy() - End a stopped timer (KRN-OBJ-001). + * @timer: Initialized timer, not armed, with no callback queued or running. + * + * @ctx thread + * @blocks no + * @time O(1) + * @owns gives:storage + * @config CONFIG_EMB_TIMER + * @req KRN-OBJ-001 KRN-TIM-028 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EBUSY (misuse) while armed, queued or running: emb_timer_stop_sync() + * first; EMB_EINVAL for a bad handle. + */ +emb_status_t emb_timer_destroy(emb_timer_t timer); + +/** + * emb_timer_start() - Arm a one-shot expiry @first from now. + * @timer: Initialized timer; a running one is re-armed and its counts kept. + * @first: Delay; it saturates to EMB_TIMEOUT_MAX_TICKS, and 0 expires at the next pass. + * + * @ctx thread isr + * @blocks no + * @time O(armed timers) insertion + * @owns none + * @config CONFIG_EMB_TIMER + * @req KRN-TIM-025 KRN-TIM-006 KRN-IRQ-033 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EINVAL for a bad handle. + */ +emb_status_t emb_timer_start(emb_timer_t timer, emb_duration_t first); + +/** + * emb_timer_start_at() - Arm a one-shot expiry at an absolute instant. + * @timer: Initialized timer. + * @at: Kernel-clock instant; a past one expires at the next pass. + * + * @ctx thread isr + * @blocks no + * @time O(armed timers) insertion + * @owns none + * @config CONFIG_EMB_TIMER + * @req KRN-TIM-025 KRN-TIM-011 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EINVAL for a bad handle. + */ +emb_status_t emb_timer_start_at(emb_timer_t timer, emb_instant_t at); + +/** + * emb_timer_start_periodic() - Arm @first from now, then every @period from each deadline. + * @timer: Initialized timer. + * @first: Delay to the first expiry. + * @period: Nonzero period; it saturates to EMB_TIMEOUT_MAX_TICKS. + * + * @ctx thread isr + * @blocks no + * @time O(armed timers) insertion + * @owns none + * @config CONFIG_EMB_TIMER + * @req KRN-TIM-025 KRN-TIM-015 KRN-TIM-027 + * @since 0.3 + * @stable no + * + * Expiry k falls at first + k * period from the start, whatever the callbacks' latency. + * Periods missed entirely are skipped and counted as overruns (emb_timer_get_counts()). + * + * Return: EMB_OK; EMB_EINVAL for a zero period (misuse) or a bad handle. + */ +emb_status_t emb_timer_start_periodic(emb_timer_t timer, emb_duration_t first, + emb_duration_t period); + +/** + * emb_timer_restart() - Re-arm with the last delay or period, from now. + * @timer: Initialized timer that was started before. + * + * @ctx thread isr + * @blocks no + * @time O(armed timers) insertion + * @owns none + * @config CONFIG_EMB_TIMER + * @req KRN-TIM-025 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_ESTATE when the timer was never started; EMB_EINVAL for a bad handle. + */ +emb_status_t emb_timer_restart(emb_timer_t timer); + +/** + * emb_timer_stop() - Disarm and drop a queued callback; never waits. + * @timer: Initialized timer. + * @out_state: Receives EMB_TIMER_RUNNING when a callback runs (it completes), + * EMB_TIMER_PENDING when an armed deadline or a queued callback was dropped, + * else EMB_TIMER_IDLE; may be NULL. + * + * @ctx thread isr + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_TIMER + * @req KRN-TIM-025 KRN-TIM-028 + * @since 0.3 + * @stable no + * + * From the timer's own callback it returns EMB_TIMER_RUNNING: the normal way for a + * periodic timer to end itself. + * + * Return: EMB_OK; EMB_EINVAL for a bad handle. + */ +emb_status_t emb_timer_stop(emb_timer_t timer, emb_timer_state_t *out_state); + +/** + * emb_timer_stop_sync() - Stop, then wait until a running callback has returned. + * @timer: Initialized timer. + * @timeout: EMB_NO_WAIT, EMB_WAIT_FOREVER or EMB_TIMEOUT(duration). + * + * @ctx thread + * @blocks timeout + * @time O(1); O(waiters + timeouts) finite wait enqueue + * @owns none + * @config CONFIG_EMB_TIMER + * @req KRN-TIM-028 KRN-WAIT-012 + * @since 0.3 + * @stable no + * + * On EMB_OK the timer can be destroyed unless something started it again meanwhile. + * + * Return: EMB_OK; EMB_ETIMEDOUT with the callback still running; EMB_ECANCELED; EMB_EPERM + * (misuse) from the timer's own callback (it would wait for itself) or outside + * thread context; EMB_EINVAL for a bad handle. + */ +emb_status_t emb_timer_stop_sync(emb_timer_t timer, emb_timeout_t timeout); + +/** + * emb_timer_is_running() - Whether a deadline is armed; false for a bad handle. + * @timer: Initialized timer. + * + * @ctx thread isr + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_TIMER + * @req KRN-TIM-025 + * @since 0.3 + * @stable no + */ +bool emb_timer_is_running(emb_timer_t timer); + +/** + * emb_timer_remaining() - Time to the armed deadline. + * @timer: Initialized timer. + * @out_rem: Receives the remaining time; zero when not armed or already due. + * + * @ctx thread isr + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_TIMER + * @req KRN-TIM-025 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EINVAL for a bad handle or a NULL @out_rem. + */ +emb_status_t emb_timer_remaining(emb_timer_t timer, emb_duration_t *out_rem); + +/** + * emb_timer_get_counts() - Expiries and overruns since init. + * @timer: Initialized timer. + * @out_expiries: Receives the expiries; may be NULL. + * @out_overruns: Receives missed periods plus callbacks coalesced into a queued one; + * may be NULL. Both counts saturate. + * + * @ctx thread isr + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_TIMER + * @req KRN-TIM-027 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EINVAL for a bad handle. + */ +emb_status_t emb_timer_get_counts(emb_timer_t timer, uint32_t *out_expiries, + uint32_t *out_overruns); + +#ifdef __cplusplus +} +#endif + +#endif /* EMB_TIMER_H */ diff --git a/include/emb/trace.h b/include/emb/trace.h index 245a87c..76ae580 100644 --- a/include/emb/trace.h +++ b/include/emb/trace.h @@ -55,9 +55,12 @@ enum emb_trace_id { EMB_TRACE_MSGQ_RECEIVE = 36, /* a0 = index, a1 = queue address, a2 = status */ EMB_TRACE_WORK_SUBMIT = 37, /* a0 = queue thread index, a1 = item address, a2 = status */ EMB_TRACE_WORK_RUN = 38, /* a0 = queue thread index, a1 = item address, a2 = 0 start, 1 end */ - EMB_TRACE_WORK_CANCEL = 39, /* a0 = index or 0xFF, a1 = item address, a2 = state it was in */ - EMB_TRACE_WORK_WAIT = 40 /* a0 = index, a1 = item or queue, a2 = status (cancel_sync, flush, - destroy) */ + EMB_TRACE_WORK_CANCEL = 39, /* a0 = index or 0xFF, a1 = item address, a2 = state it was in */ + EMB_TRACE_WORK_WAIT = 40, /* a0 = index, a1 = item or queue, a2 = status (cancel_sync, flush, + destroy, timer stop_sync) */ + EMB_TRACE_TIMER_EXPIRE = 41, /* a0 = periods skipped, a1 = timer address, a2 = expiries */ + EMB_TRACE_TIMER_START = 42, /* a0 = deadline low word, a1 = timer address, a2 = period */ + EMB_TRACE_TIMER_STOP = 43 /* a0 = index or 0xFF, a1 = timer address, a2 = state found */ }; typedef struct emb_trace_event { diff --git a/kernel/CMakeLists.txt b/kernel/CMakeLists.txt index 6b97a05..2969706 100644 --- a/kernel/CMakeLists.txt +++ b/kernel/CMakeLists.txt @@ -36,6 +36,9 @@ endif() if(CONFIG_EMB_WORKQ) list(APPEND EMB_KERNEL_SOURCES work/workq.c) endif() +if(CONFIG_EMB_TIMER) + list(APPEND EMB_KERNEL_SOURCES time/timer.c) +endif() if(CONFIG_EMB_SYSTEM_WORKQ) list(APPEND EMB_KERNEL_SOURCES work/system_workq.c) # linked only when referenced (ADR-048) endif() diff --git a/kernel/Kconfig b/kernel/Kconfig index bce7769..b32aa95 100644 --- a/kernel/Kconfig +++ b/kernel/Kconfig @@ -241,6 +241,19 @@ config EMB_IPC_SMALL endmenu +menu "Software timers" + +config EMB_TIMER + bool "Software timers (SPEC-003 §7)" + depends on !EMB_SCHED_TABLE + default y + help + One-shot and periodic timers whose callbacks run on a work queue, or in the expiry + interrupt with EMB_TIMER_ISR_CONTEXT. The tiny profile's table scheduler has no + timers in M2. + +endmenu + menu "Work queues" config EMB_WORKQ diff --git a/kernel/include/embk/kernel.h b/kernel/include/embk/kernel.h index ec11ef6..55cfd13 100644 --- a/kernel/include/embk/kernel.h +++ b/kernel/include/embk/kernel.h @@ -43,6 +43,7 @@ #define EMBK_OBJ_COND ((uint8_t)0x55u) #define EMBK_OBJ_MSGQ ((uint8_t)0x56u) #define EMBK_OBJ_WORKQ ((uint8_t)0x57u) +#define EMBK_OBJ_TIMER ((uint8_t)0x58u) #define EMBK_OBJ_UNINIT ((uint8_t)0u) #define EMBK_OBJ_ACTIVE ((uint8_t)1u) diff --git a/kernel/include/embk/time.h b/kernel/include/embk/time.h index f746244..24e120f 100644 --- a/kernel/include/embk/time.h +++ b/kernel/include/embk/time.h @@ -22,4 +22,13 @@ bool embk_timeout_next(emb_tick_t *out_deadline); /* Tickless: reprogram the hardware deadline from the next timeout, lock held. */ void embk_time_program(void); +#if CONFIG_EMB_TIMER +/* The timer list (ADR-047), lock held: thread timeouts and timers expire together in + * deadline order, threads first on equal deadlines. The arm records the current pass. */ +void embk_timer_node_arm(embk_timeout_t *node, emb_tick_t deadline); +void embk_timer_node_disarm(embk_timeout_t *node); +/* The expiry pass in progress, or the last one; a node armed in it waits for the next. */ +uint8_t embk_time_pass(void); +#endif + #endif /* EMBK_TIME_H */ diff --git a/kernel/include/embk/timer.h b/kernel/include/embk/timer.h new file mode 100644 index 0000000..d5e510e --- /dev/null +++ b/kernel/include/embk/timer.h @@ -0,0 +1,47 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* Software timer objects (SPEC-003 §7). Included by the layout probe, so it names the + * public handle only as an incomplete type. */ +#ifndef EMBK_TIMER_H +#define EMBK_TIMER_H + +#include +#if CONFIG_EMB_WORKQ +#include +#endif + +#if CONFIG_EMB_TIMER +struct emb_timer_t; + +#define EMBK_TIMER_STARTED ((uint8_t)0x02u) /* obj.flags: restart has an interval */ + +typedef struct embk_timer { + embk_obj_t obj; /* obj.flags: EMB_TIMER_ISR_CONTEXT | EMBK_TIMER_STARTED */ + embk_timeout_t node; /* in the timer list while armed (ADR-047) */ + emb_tick_t period; /* 0: one-shot */ + emb_tick_t interval; /* what restart re-arms with: the first delay or the period */ + uint32_t expiries; /* saturating */ + uint32_t overruns; /* saturating: missed periods and coalesced callbacks */ + void (*fn)(struct emb_timer_t timer, void *arg); + void *arg; + uint8_t pass; /* the expiry pass that armed it: due again only in a later one */ + uint8_t in_callback; /* an EMB_TIMER_ISR_CONTEXT callback runs */ +#if CONFIG_EMB_WORKQ + embk_workq_t *queue; /* where the callback runs; NULL with EMB_TIMER_ISR_CONTEXT */ + embk_work_t work; +#endif +#if CONFIG_EMB_OBJ_LIST + embk_list_node_t all_node; /* in embk_timer_all from init to destroy */ +#endif +} embk_timer_t; + +#if CONFIG_EMB_OBJ_LIST +extern embk_list_t embk_timer_all; /* every active timer; lock: scheduler domain */ +#endif + +/* An armed timer's deadline passed (SPEC-003 §7.4); called by the expiry pass with the + * timeout lock held as *key, which an EMB_TIMER_ISR_CONTEXT callback runs without. */ +void embk_timer_expired(embk_timer_t *t, emb_tick_t now, emb_irq_key_t *key); +#endif + +#endif /* EMBK_TIMER_H */ diff --git a/kernel/include/embk/work.h b/kernel/include/embk/work.h index c5a4f6e..cfd58e1 100644 --- a/kernel/include/embk/work.h +++ b/kernel/include/embk/work.h @@ -31,6 +31,7 @@ typedef struct embk_workq { embk_wait_queue_t done_q; /* cancel_sync and flush; wake_data holds the awaited item */ embk_work_t *running; /* the item whose handler runs, or NULL */ uint8_t stopping; /* destroy has begun: the thread exits at an empty FIFO */ + uint8_t timer_refs; /* timers whose callbacks run here: destroy is refused */ #if CONFIG_EMB_OBJ_LIST embk_list_node_t all_node; /* in embk_workq_all from init to destroy */ #endif @@ -39,6 +40,19 @@ typedef struct embk_workq { #if CONFIG_EMB_OBJ_LIST extern embk_list_t embk_workq_all; /* every active work queue; lock: scheduler domain */ #endif + +/* The queue a handle's raw value names, or NULL. */ +embk_workq_t *embk_workq_from_raw(uintptr_t raw); +/* Initialize @w to run @fn (emb_work_init()). */ +void embk_work_init(embk_work_t *w, void (*fn)(struct emb_work *work)); +/* emb_work_submit() on resolved objects, lock held; the caller reschedules. */ +emb_status_t embk_work_submit_locked(embk_workq_t *q, embk_work_t *w); +/* emb_work_cancel() on a resolved item, lock held; returns the state it was in. */ +uint8_t embk_work_cancel_locked(embk_work_t *w); +/* emb_work_cancel_sync() on a resolved item, with faults and traces naming @api_id and the + * deadline fixed by the caller; thread context. */ +emb_status_t embk_work_cancel_sync(uint16_t api_id, embk_work_t *w, emb_tick_t deadline, + bool nowait); #endif #endif /* EMBK_WORK_H */ diff --git a/kernel/object/storage_probe.c b/kernel/object/storage_probe.c index 437a94c..03766bb 100644 --- a/kernel/object/storage_probe.c +++ b/kernel/object/storage_probe.c @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -44,4 +45,7 @@ void embk_storage_probe(void) PROBE(work, embk_work_t); PROBE(workq, embk_workq_t); #endif +#if CONFIG_EMB_TIMER + PROBE(timer, embk_timer_t); +#endif } diff --git a/kernel/time/time.c b/kernel/time/time.c index 407529e..2d9688f 100644 --- a/kernel/time/time.c +++ b/kernel/time/time.c @@ -10,6 +10,7 @@ #include #include #include +#include #include typedef struct embk_time_state { @@ -23,6 +24,10 @@ typedef struct embk_time_state { #else embk_list_t timeouts; /* ordered by deadline; lock: timeout domain */ #endif +#if CONFIG_EMB_TIMER + embk_list_t timers; /* software timers, ordered by deadline (ADR-047); lock: timeout domain */ + uint8_t pass; /* counts expiry passes */ +#endif #if CONFIG_EMB_TICKLESS emb_arch_timer_raw_t last_raw; /* counter value at which ticks was last exact */ uint32_t raw_per_tick; @@ -152,32 +157,42 @@ static void tl_remove(embk_timeout_t *node) node->next = NULL; } #else -static EMB_INLINE embk_timeout_t *tl_first(void) +static EMB_INLINE embk_timeout_t *tl_first_in(const embk_list_t *l) { - embk_list_node_t *n = embk_list_first(&ts.timeouts); + embk_list_node_t *n = embk_list_first(l); return (n == NULL) ? NULL : EMB_CONTAINER_OF(n, embk_timeout_t, node); } +static EMB_INLINE embk_timeout_t *tl_first(void) +{ + return tl_first_in(&ts.timeouts); +} + static EMB_INLINE bool tl_is_linked(const embk_timeout_t *n) { return embk_list_node_is_linked(&n->node); } -static void tl_insert(embk_timeout_t *node) +static void tl_insert_in(embk_list_t *l, embk_timeout_t *node) { /* from the tail: a new deadline is most often later than most; bounded by the armed nodes */ - embk_list_node_t *p = embk_list_last(&ts.timeouts); + embk_list_node_t *p = embk_list_last(l); while (p != NULL && embk_tick_before(node->deadline, EMB_CONTAINER_OF(p, embk_timeout_t, node)->deadline)) { - p = embk_list_prev(&ts.timeouts, p); + p = embk_list_prev(l, p); } if (p == NULL) { - embk_list_prepend(&ts.timeouts, &node->node); + embk_list_prepend(l, &node->node); } else { embk_list_insert_after(p, &node->node); /* equal deadlines expire in arming order */ } } +static EMB_INLINE void tl_insert(embk_timeout_t *node) +{ + tl_insert_in(&ts.timeouts, node); +} + static EMB_INLINE void tl_remove(embk_timeout_t *node) { embk_list_remove(&node->node); @@ -223,9 +238,54 @@ bool embk_timeout_is_armed(const embk_timeout_t *node) return tl_is_linked(node); } +#if CONFIG_EMB_TIMER +void embk_timer_node_arm(embk_timeout_t *node, emb_tick_t deadline) +{ + EMBK_ASSERT(!tl_is_linked(node)); + node->deadline = deadline; + EMB_CONTAINER_OF(node, embk_timer_t, node)->pass = ts.pass; + tl_insert_in(&ts.timers, node); +#if CONFIG_EMB_TICKLESS + if (tl_first_in(&ts.timers) == node) { + embk_time_program(); + } +#endif +} + +void embk_timer_node_disarm(embk_timeout_t *node) +{ +#if CONFIG_EMB_TICKLESS + bool was_head; +#endif + if (!tl_is_linked(node)) { + return; + } +#if CONFIG_EMB_TICKLESS + was_head = tl_first_in(&ts.timers) == node; +#endif + embk_list_remove(&node->node); +#if CONFIG_EMB_TICKLESS + if (was_head) { + embk_time_program(); + } +#endif +} + +uint8_t embk_time_pass(void) +{ + return ts.pass; +} +#endif + bool embk_timeout_next(emb_tick_t *out_deadline) { const embk_timeout_t *n = tl_first(); +#if CONFIG_EMB_TIMER + const embk_timeout_t *m = tl_first_in(&ts.timers); + if (m != NULL && (n == NULL || embk_tick_before(m->deadline, n->deadline))) { + n = m; + } +#endif if (n == NULL) { return false; } @@ -233,13 +293,39 @@ bool embk_timeout_next(emb_tick_t *out_deadline) return true; } -/* Expire every node whose deadline is at or before @now, in deadline order (§5.3). */ -static void expire(emb_tick_t now) +/* Expire every node whose deadline is at or before @now, in deadline order (§5.3): the + * earlier of the two lists' heads first, a thread timeout first on equal deadlines, and + * each timer at most once per pass (ADR-047). @key is the timeout lock, which an + * EMB_TIMER_ISR_CONTEXT callback runs without. */ +static void expire(emb_tick_t now, emb_irq_key_t *key) { embk_timeout_t *node; - /* bounded by the number of expired nodes */ - while ((node = tl_first()) != NULL) { - if (embk_tick_before(now, node->deadline)) { +#if CONFIG_EMB_TIMER + bool timers_done = false; + ts.pass++; +#endif + (void)key; + /* bounded by the number of expired nodes; a timer re-armed in this pass waits */ + for (;;) { + node = tl_first(); +#if CONFIG_EMB_TIMER + if (!timers_done) { + embk_timeout_t *m = tl_first_in(&ts.timers); + if (m != NULL && !embk_tick_before(now, m->deadline) && + (node == NULL || embk_tick_before(m->deadline, node->deadline) || + embk_tick_before(now, node->deadline))) { + embk_timer_t *t = EMB_CONTAINER_OF(m, embk_timer_t, node); + if (t->pass == ts.pass) { + timers_done = true; /* armed in this pass: the next one expires it */ + continue; + } + embk_list_remove(&m->node); + embk_timer_expired(t, now, key); + continue; + } + } +#endif + if (node == NULL || embk_tick_before(now, node->deadline)) { break; } tl_remove(node); @@ -299,6 +385,10 @@ void embk_time_init(void) #else embk_list_init(&ts.timeouts); #endif +#if CONFIG_EMB_TIMER + embk_list_init(&ts.timers); + ts.pass = 0u; +#endif } void embk_time_start(void) @@ -329,7 +419,7 @@ void embk_time_timer_isr(void) clock_write_end(); EMBK_TRACE(EMB_TRACE_TICK, (uint32_t)ts.ticks, 0u, 0u); #endif - expire(ts.ticks); + expire(ts.ticks, &key); embk_time_program(); embk_unlock_timeout(key); } @@ -339,7 +429,7 @@ void embk_time_on_wake(void) #if CONFIG_EMB_TICKLESS emb_irq_key_t key = embk_lock_timeout(); (void)update(); - expire(ts.ticks); + expire(ts.ticks, &key); embk_time_program(); embk_unlock_timeout(key); #endif diff --git a/kernel/time/timer.c b/kernel/time/timer.c new file mode 100644 index 0000000..95c41b5 --- /dev/null +++ b/kernel/time/timer.c @@ -0,0 +1,418 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* + * Software timers (SPEC-003 §7). An armed timer sits in its own deadline list (ADR-047), + * which the expiry pass merges with the thread timeouts. A periodic timer re-arms from + * its previous deadline before its callback runs, so expiry k falls at first + k * period; + * periods already past at expiry are skipped in closed form and counted as overruns. The + * callback runs on the timer's work queue, where an expiry whose previous callback is still + * queued coalesces into it (an overrun), or with EMB_TIMER_ISR_CONTEXT in the expiry pass. + */ +#include +#include + +#include +#include +#include +#include +#include +#if CONFIG_EMB_WORKQ +#include +#endif + +#if CONFIG_EMB_TIMER + +EMB_STATIC_ASSERT(sizeof(embk_timer_t) <= EMB_TIMER_STORAGE_SIZE, "generated storage too small"); + +#if CONFIG_EMB_OBJ_LIST +embk_list_t embk_timer_all = EMBK_LIST_INITIALIZER(embk_timer_all); /* usable before kernel init */ +#endif + +static EMB_INLINE embk_timer_t *timer_from_handle(emb_timer_t h) +{ + return (embk_timer_t *)embk_obj_from_raw(h.raw, EMBK_OBJ_TIMER, offsetof(embk_timer_t, obj)); +} + +static EMB_INLINE emb_timer_t handle_of(const embk_timer_t *t) +{ + emb_timer_t h; + h.raw = embk_obj_to_raw(t); + return h; +} + +static EMB_INLINE uint32_t add_sat_u32(uint32_t a, uint32_t b) +{ + return (a > UINT32_MAX - b) ? UINT32_MAX : a + b; +} + +/* The finite tick count a duration arms with (SPEC-003 §5.2). */ +static EMB_INLINE emb_tick_t interval_of(emb_duration_t d) +{ + return emb_timeout_from_duration(d).ticks; +} + +#if CONFIG_EMB_WORKQ +static void timer_work(emb_work_t *work) +{ + embk_timer_t *t = EMB_CONTAINER_OF((embk_work_t *)(void *)work, embk_timer_t, work); + t->fn(handle_of(t), t->arg); +} +#endif + +void embk_timer_expired(embk_timer_t *t, emb_tick_t now, emb_irq_key_t *key) +{ + uint32_t missed = 0u; + t->expiries = add_sat_u32(t->expiries, 1u); + if (t->period != 0u) { + emb_tick_t next = t->node.deadline + t->period; /* from the previous deadline */ + if (!embk_tick_before(now, next)) { + /* whole periods already past, in closed form (§16): next lands after now */ + emb_tick_t skip = ((emb_tick_t)(now - next) / t->period) + 1u; +#if CONFIG_EMB_TICK_32BIT + missed = (uint32_t)skip; +#else + missed = (skip > UINT32_MAX) ? UINT32_MAX : (uint32_t)skip; +#endif + next += skip * t->period; + } + t->overruns = add_sat_u32(t->overruns, missed); + embk_timer_node_arm(&t->node, next); /* before the callback: it may stop the timer */ + } + EMBK_TRACE(EMB_TRACE_TIMER_EXPIRE, missed, t, t->expiries); +#if CONFIG_EMB_WORKQ + if (t->queue != NULL) { + /* a callback already queued, or due again after the running one, absorbs this + * expiry: an overrun (§7.4) */ + if (t->work.state == EMB_WORK_PENDING || + (t->work.state == EMB_WORK_RUNNING && (t->work.flags & EMBK_WORK_RESUBMITTED) != 0u)) { + t->overruns = add_sat_u32(t->overruns, 1u); + } else { + (void)embk_work_submit_locked(t->queue, &t->work); + } + return; + } +#endif + /* EMB_TIMER_ISR_CONTEXT: interrupt context, the timeout structure released (§7.3) */ + t->in_callback = 1u; + embk_unlock_timeout(*key); + t->fn(handle_of(t), t->arg); + *key = embk_lock_timeout(); + t->in_callback = 0u; +} + +void emb_timer_attr_default(emb_timer_attr_t *out_attr) +{ + if (out_attr == NULL) { + return; + } + out_attr->name = NULL; + out_attr->fn = NULL; + out_attr->arg = NULL; +#if CONFIG_EMB_WORKQ + out_attr->workq.raw = 0u; +#endif + out_attr->flags = 0u; + out_attr->reserved_[0] = 0u; + out_attr->reserved_[1] = 0u; + out_attr->reserved_[2] = 0u; +} + +emb_status_t emb_timer_init(emb_timer_storage_t *storage, const emb_timer_attr_t *attr, + emb_timer_t *out_timer) +{ + embk_timer_t *t; + emb_irq_key_t key; + bool isr; +#if CONFIG_EMB_WORKQ + embk_workq_t *q = NULL; +#endif + if (out_timer != NULL) { + out_timer->raw = 0u; + } + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_TIMER_INIT); + EMBK_API_REQUIRE(EMB_API_ID_TIMER_INIT, storage != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, + 1u); + EMBK_API_REQUIRE(EMB_API_ID_TIMER_INIT, + attr != NULL && attr->fn != NULL && + (attr->flags & (uint8_t)~EMB_TIMER_ISR_CONTEXT) == 0u, + EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); + EMBK_API_REQUIRE(EMB_API_ID_TIMER_INIT, out_timer != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, + 3u); + EMBK_API_REQUIRE(EMB_API_ID_TIMER_INIT, + ((uintptr_t)storage % (uintptr_t)EMB_TIMER_STORAGE_ALIGN) == 0u, + EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + isr = (attr->flags & EMB_TIMER_ISR_CONTEXT) != 0u; +#if CONFIG_EMB_WORKQ + if (!isr) { + if (attr->workq.raw == 0u) { + return EMB_ENOTSUP; /* no implicit queue: name one, or ISR context (A55) */ + } + q = embk_workq_from_raw(attr->workq.raw); + EMBK_API_REQUIRE(EMB_API_ID_TIMER_INIT, q != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 2u); + } +#else + if (!isr) { + return EMB_ENOTSUP; /* thread-context callbacks need CONFIG_EMB_WORKQ */ + } +#endif + t = (embk_timer_t *)(void *)storage; + key = embk_lock_sched(); +#if CONFIG_EMB_OBJ_LIST + /* storage is reused only through destroy (SPEC-009 §6, KRN-OBJ-007) */ + if (embk_list_contains(&embk_timer_all, &t->all_node)) { + embk_unlock_sched(key); + EMBK_API_MISUSE(EMB_API_ID_TIMER_INIT, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); + } +#endif +#if CONFIG_EMB_WORKQ + if (q != NULL) { + if (q->timer_refs == UINT8_MAX) { + embk_unlock_sched(key); + return EMB_ENOMEM; + } + q->timer_refs++; /* the queue outlives the timer (E2) */ + } + t->queue = q; + embk_work_init(&t->work, timer_work); +#endif + embk_obj_init(&t->obj, EMBK_OBJ_TIMER, attr->flags & EMB_TIMER_ISR_CONTEXT, attr->name); + embk_list_node_init(&t->node.node); + t->period = 0u; + t->interval = 0u; + t->expiries = 0u; + t->overruns = 0u; + t->fn = attr->fn; + t->arg = attr->arg; + t->pass = 0u; + t->in_callback = 0u; +#if CONFIG_EMB_OBJ_LIST + embk_list_append(&embk_timer_all, &t->all_node); +#endif + embk_unlock_sched(key); + out_timer->raw = embk_obj_to_raw(t); + return EMB_OK; +} + +/* The state stop finds and leaves: disarmed, a queued callback dropped; lock held. */ +static emb_timer_state_t stop_locked(embk_timer_t *t) +{ + emb_timer_state_t st = EMB_TIMER_IDLE; + if (embk_list_node_is_linked(&t->node.node)) { + embk_timer_node_disarm(&t->node); + st = EMB_TIMER_PENDING; + } +#if CONFIG_EMB_WORKQ + if (t->queue != NULL) { + uint8_t was = embk_work_cancel_locked(&t->work); + if (was == EMB_WORK_PENDING) { + st = EMB_TIMER_PENDING; + } else if (was == EMB_WORK_RUNNING) { + st = EMB_TIMER_RUNNING; + } else { + /* the callback is neither queued nor running */ + } + } +#endif + if (t->in_callback != 0u) { + st = EMB_TIMER_RUNNING; /* from its own ISR_CONTEXT callback */ + } + return st; +} + +emb_status_t emb_timer_destroy(emb_timer_t timer) +{ + embk_timer_t *t = timer_from_handle(timer); + emb_irq_key_t key; + bool busy; + EMBK_API_REQUIRE_NOT_ISR(EMB_API_ID_TIMER_DESTROY); + EMBK_API_REQUIRE(EMB_API_ID_TIMER_DESTROY, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + key = embk_lock_sched(); + busy = embk_list_node_is_linked(&t->node.node) || t->in_callback != 0u; +#if CONFIG_EMB_WORKQ + busy = busy || (t->queue != NULL && t->work.state != EMB_WORK_IDLE); +#endif + if (busy) { + embk_unlock_sched(key); + EMBK_API_MISUSE(EMB_API_ID_TIMER_DESTROY, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); + } +#if CONFIG_EMB_WORKQ + if (t->queue != NULL) { + t->queue->timer_refs--; + } +#endif +#if CONFIG_EMB_OBJ_LIST + embk_list_remove(&t->all_node); +#endif + t->obj.state = EMBK_OBJ_UNINIT; + embk_unlock_sched(key); + return EMB_OK; +} + +/* (Re)arm at @deadline; lock held. The counts are kept. */ +static void arm_locked(embk_timer_t *t, emb_tick_t deadline, emb_tick_t period, emb_tick_t interval) +{ + embk_timer_node_disarm(&t->node); + t->period = period; + t->interval = interval; + t->obj.flags |= EMBK_TIMER_STARTED; + embk_timer_node_arm(&t->node, deadline); + EMBK_TRACE(EMB_TRACE_TIMER_START, (uint32_t)deadline, t, (uint32_t)period); +} + +static emb_status_t start_common(uint16_t api_id, emb_timer_t timer, emb_tick_t first, + emb_tick_t period) +{ + embk_timer_t *t = timer_from_handle(timer); + emb_irq_key_t key; + emb_timeout_t after; + EMBK_API_REQUIRE(api_id, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + after.ticks = first; + key = embk_lock_timeout(); + arm_locked(t, embk_deadline_from_timeout(after), period, (period != 0u) ? period : first); + embk_unlock_timeout(key); + return EMB_OK; +} + +emb_status_t emb_timer_start(emb_timer_t timer, emb_duration_t first) +{ + return start_common(EMB_API_ID_TIMER_START, timer, interval_of(first), 0u); +} + +emb_status_t emb_timer_start_at(emb_timer_t timer, emb_instant_t at) +{ + embk_timer_t *t = timer_from_handle(timer); + emb_irq_key_t key; + emb_tick_t now; + EMBK_API_REQUIRE(EMB_API_ID_TIMER_START_AT, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + key = embk_lock_timeout(); + now = embk_time_ticks_locked(); + arm_locked(t, at.ticks, 0u, + embk_tick_before(now, at.ticks) ? (emb_tick_t)(at.ticks - now) : 0u); + embk_unlock_timeout(key); + return EMB_OK; +} + +emb_status_t emb_timer_start_periodic(emb_timer_t timer, emb_duration_t first, + emb_duration_t period) +{ + EMBK_API_REQUIRE(EMB_API_ID_TIMER_START_PERIODIC, period.ticks != 0u, EMB_FAULT_API_ARGUMENT, + EMB_EINVAL, 3u); + return start_common(EMB_API_ID_TIMER_START_PERIODIC, timer, interval_of(first), + interval_of(period)); +} + +emb_status_t emb_timer_restart(emb_timer_t timer) +{ + embk_timer_t *t = timer_from_handle(timer); + emb_irq_key_t key; + emb_timeout_t after; + EMBK_API_REQUIRE(EMB_API_ID_TIMER_RESTART, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + key = embk_lock_timeout(); + if ((t->obj.flags & EMBK_TIMER_STARTED) == 0u) { + embk_unlock_timeout(key); + return EMB_ESTATE; + } + after.ticks = t->interval; + arm_locked(t, embk_deadline_from_timeout(after), t->period, t->interval); + embk_unlock_timeout(key); + return EMB_OK; +} + +static EMB_INLINE uintptr_t caller_index(void) +{ + return (embk_cpu.current != NULL) ? embk_thread_index(embk_cpu.current) : 0xFFu; +} + +emb_status_t emb_timer_stop(emb_timer_t timer, emb_timer_state_t *out_state) +{ + embk_timer_t *t = timer_from_handle(timer); + emb_irq_key_t key; + emb_timer_state_t st; + EMBK_API_REQUIRE(EMB_API_ID_TIMER_STOP, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + key = embk_lock_timeout(); + st = stop_locked(t); + EMBK_TRACE(EMB_TRACE_TIMER_STOP, caller_index(), t, st); + embk_unlock_timeout(key); + if (out_state != NULL) { + *out_state = st; + } + return EMB_OK; +} + +emb_status_t emb_timer_stop_sync(emb_timer_t timer, emb_timeout_t timeout) +{ + embk_timer_t *t = timer_from_handle(timer); + emb_tick_t deadline = embk_deadline_from_timeout(timeout); /* fixed at the call */ + emb_irq_key_t key; + emb_timer_state_t st; + EMBK_API_REQUIRE_THREAD(EMB_API_ID_TIMER_STOP_SYNC); + if (t == NULL) { + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(embk_cpu.current), 0u, EMB_EINVAL); + EMBK_API_MISUSE(EMB_API_ID_TIMER_STOP_SYNC, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + } + key = embk_lock_timeout(); +#if CONFIG_EMB_WORKQ + if (t->queue != NULL && embk_cpu.current == &t->queue->thread && + t->work.state == EMB_WORK_RUNNING) { + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(embk_cpu.current), &t->work, EMB_EPERM); + embk_unlock_timeout(key); + EMBK_API_MISUSE(EMB_API_ID_TIMER_STOP_SYNC, EMB_FAULT_API_CONTEXT, EMB_EPERM, 1u); + } +#endif + st = stop_locked(t); + EMBK_TRACE(EMB_TRACE_TIMER_STOP, embk_thread_index(embk_cpu.current), t, st); + embk_unlock_timeout(key); +#if CONFIG_EMB_WORKQ + if (t->queue != NULL) { + /* waits only while the callback still runs; traces every outcome (A19) */ + return embk_work_cancel_sync(EMB_API_ID_TIMER_STOP_SYNC, &t->work, deadline, + EMB_TIMEOUT_IS_NO_WAIT(timeout)); + } +#endif + (void)deadline; + (void)st; + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(embk_cpu.current), t, EMB_OK); + return EMB_OK; /* an ISR_CONTEXT callback cannot be running under a thread */ +} + +bool emb_timer_is_running(emb_timer_t timer) +{ + const embk_timer_t *t = timer_from_handle(timer); + return t != NULL && embk_list_node_is_linked(&t->node.node); +} + +emb_status_t emb_timer_remaining(emb_timer_t timer, emb_duration_t *out_rem) +{ + const embk_timer_t *t = timer_from_handle(timer); + emb_irq_key_t key; + emb_tick_t now; + EMBK_API_REQUIRE(EMB_API_ID_TIMER_REMAINING, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + EMBK_API_REQUIRE(EMB_API_ID_TIMER_REMAINING, out_rem != NULL, EMB_FAULT_API_ARGUMENT, + EMB_EINVAL, 2u); + key = embk_lock_timeout(); + now = embk_time_ticks_locked(); + out_rem->ticks = + (embk_list_node_is_linked(&t->node.node) && embk_tick_before(now, t->node.deadline)) + ? (emb_tick_t)(t->node.deadline - now) + : 0u; + embk_unlock_timeout(key); + return EMB_OK; +} + +emb_status_t emb_timer_get_counts(emb_timer_t timer, uint32_t *out_expiries, uint32_t *out_overruns) +{ + const embk_timer_t *t = timer_from_handle(timer); + emb_irq_key_t key; + EMBK_API_REQUIRE(EMB_API_ID_TIMER_GET_COUNTS, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + key = embk_lock_timeout(); + if (out_expiries != NULL) { + *out_expiries = t->expiries; + } + if (out_overruns != NULL) { + *out_overruns = t->overruns; + } + embk_unlock_timeout(key); + return EMB_OK; +} + +#endif /* CONFIG_EMB_TIMER */ diff --git a/kernel/work/workq.c b/kernel/work/workq.c index beac3d2..4174f29 100644 --- a/kernel/work/workq.c +++ b/kernel/work/workq.c @@ -27,9 +27,14 @@ EMB_STATIC_ASSERT(sizeof(embk_work_t) <= sizeof(emb_work_t), "generated storage embk_list_t embk_workq_all = EMBK_LIST_INITIALIZER(embk_workq_all); /* usable before kernel init */ #endif +embk_workq_t *embk_workq_from_raw(uintptr_t raw) +{ + return (embk_workq_t *)embk_obj_from_raw(raw, EMBK_OBJ_WORKQ, offsetof(embk_workq_t, obj)); +} + static EMB_INLINE embk_workq_t *workq_from_handle(emb_workq_t h) { - return (embk_workq_t *)embk_obj_from_raw(h.raw, EMBK_OBJ_WORKQ, offsetof(embk_workq_t, obj)); + return embk_workq_from_raw(h.raw); } static EMB_INLINE embk_work_t *work_of(const emb_work_t *work) @@ -170,6 +175,7 @@ emb_status_t emb_workq_init(emb_workq_storage_t *storage, const emb_workq_attr_t embk_wait_queue_init(&q->done_q, 0u); q->running = NULL; q->stopping = 0u; + q->timer_refs = 0u; emb_thread_attr_default(&ta); ta.name = attr->name; ta.stack = attr->stack; @@ -205,7 +211,7 @@ emb_status_t emb_workq_destroy(emb_workq_t workq) } key = embk_lock_object(q); if (self == &q->thread || !embk_list_is_empty(&q->fifo) || q->running != NULL || - q->stopping != 0u || !embk_wait_queue_is_empty(&q->done_q)) { + q->stopping != 0u || q->timer_refs != 0u || !embk_wait_queue_is_empty(&q->done_q)) { EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), q, EMB_EBUSY); embk_unlock_object(q, key); EMBK_API_MISUSE(EMB_API_ID_WORKQ_DESTROY, EMB_FAULT_API_LIFECYCLE, EMB_EBUSY, 1u); @@ -300,12 +306,8 @@ emb_status_t emb_workq_flush(emb_workq_t workq, emb_timeout_t timeout) return st; } -void emb_work_init(emb_work_t *work, emb_work_fn_t fn) +void embk_work_init(embk_work_t *w, void (*fn)(struct emb_work *work)) { - embk_work_t *w = (embk_work_t *)(void *)work; - if (w == NULL) { - return; - } embk_list_node_init(&w->node); w->fn = fn; w->queue = NULL; @@ -314,16 +316,16 @@ void emb_work_init(emb_work_t *work, emb_work_fn_t fn) w->flags = 0u; } -emb_status_t emb_work_submit(emb_workq_t workq, emb_work_t *work) +void emb_work_init(emb_work_t *work, emb_work_fn_t fn) +{ + if (work != NULL) { + embk_work_init((embk_work_t *)(void *)work, fn); + } +} + +emb_status_t embk_work_submit_locked(embk_workq_t *q, embk_work_t *w) { - embk_workq_t *q = workq_from_handle(workq); - embk_work_t *w = work_of(work); - emb_irq_key_t key; emb_status_t st = EMB_OK; - EMBK_API_REQUIRE(EMB_API_ID_WORK_SUBMIT, q != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); - EMBK_API_REQUIRE(EMB_API_ID_WORK_SUBMIT, w != NULL && w->fn != NULL, EMB_FAULT_API_ARGUMENT, - EMB_EINVAL, 2u); - key = embk_lock_object(q); if (q->stopping != 0u) { st = EMB_ESTATE; /* being destroyed */ } else if (w->state == EMB_WORK_PENDING) { @@ -341,19 +343,28 @@ emb_status_t emb_work_submit(emb_workq_t workq, emb_work_t *work) embk_notify_set_locked(&q->thread, EMB_NOTIFY_K_WORK); } EMBK_TRACE(EMB_TRACE_WORK_SUBMIT, embk_thread_index(&q->thread), w, st); - embk_sched_reschedule_if_needed(); - embk_unlock_object(q, key); return st; } -emb_status_t emb_work_cancel(emb_work_t *work, emb_work_state_t *out_state) +emb_status_t emb_work_submit(emb_workq_t workq, emb_work_t *work) { + embk_workq_t *q = workq_from_handle(workq); embk_work_t *w = work_of(work); emb_irq_key_t key; - emb_work_state_t was; - EMBK_API_REQUIRE(EMB_API_ID_WORK_CANCEL, w != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); - key = embk_lock_sched(); - was = w->state; + emb_status_t st; + EMBK_API_REQUIRE(EMB_API_ID_WORK_SUBMIT, q != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + EMBK_API_REQUIRE(EMB_API_ID_WORK_SUBMIT, w != NULL && w->fn != NULL, EMB_FAULT_API_ARGUMENT, + EMB_EINVAL, 2u); + key = embk_lock_object(q); + st = embk_work_submit_locked(q, w); + embk_sched_reschedule_if_needed(); + embk_unlock_object(q, key); + return st; +} + +uint8_t embk_work_cancel_locked(embk_work_t *w) +{ + uint8_t was = w->state; if (was == EMB_WORK_PENDING) { unlink_locked(w); } else if (was == EMB_WORK_RUNNING) { @@ -362,6 +373,17 @@ emb_status_t emb_work_cancel(emb_work_t *work, emb_work_state_t *out_state) /* idle: nothing to cancel */ } EMBK_TRACE(EMB_TRACE_WORK_CANCEL, caller_index(), w, was); + return was; +} + +emb_status_t emb_work_cancel(emb_work_t *work, emb_work_state_t *out_state) +{ + embk_work_t *w = work_of(work); + emb_irq_key_t key; + emb_work_state_t was; + EMBK_API_REQUIRE(EMB_API_ID_WORK_CANCEL, w != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + key = embk_lock_sched(); + was = embk_work_cancel_locked(w); embk_unlock_sched(key); if (out_state != NULL) { *out_state = was; @@ -369,31 +391,23 @@ emb_status_t emb_work_cancel(emb_work_t *work, emb_work_state_t *out_state) return EMB_OK; } -emb_status_t emb_work_cancel_sync(emb_work_t *work, emb_timeout_t timeout) +emb_status_t embk_work_cancel_sync(uint16_t api_id, embk_work_t *w, emb_tick_t deadline, + bool nowait) { - embk_work_t *w = work_of(work); - embk_thread_t *self; + embk_thread_t *self = embk_cpu.current; embk_workq_t *q; emb_irq_key_t key; embk_wait_result_t r; uintptr_t data; emb_status_t st; bool must_wait; - emb_tick_t deadline = embk_deadline_from_timeout(timeout); /* fixed at the call */ - EMBK_API_REQUIRE_THREAD(EMB_API_ID_WORK_CANCEL_SYNC); - self = embk_cpu.current; - if (w == NULL) { - EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), 0u, EMB_EINVAL); - EMBK_API_MISUSE(EMB_API_ID_WORK_CANCEL_SYNC, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); - } - EMBK_API_REQUIRE_CAN_BLOCK(EMB_API_ID_WORK_CANCEL_SYNC, - EMB_TIMEOUT_IS_NO_WAIT(timeout) ? 0u : 1u); + EMBK_API_REQUIRE_CAN_BLOCK(api_id, nowait ? 0u : 1u); key = embk_lock_sched(); q = w->queue; if (q != NULL && self == &q->thread) { EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(self), w, EMB_EPERM); embk_unlock_sched(key); - EMBK_API_MISUSE(EMB_API_ID_WORK_CANCEL_SYNC, EMB_FAULT_API_CONTEXT, EMB_EPERM, 1u); + EMBK_API_MISUSE(api_id, EMB_FAULT_API_CONTEXT, EMB_EPERM, 1u); } must_wait = false; if (embk_thread_take_cancel_locked(self)) { @@ -405,7 +419,7 @@ emb_status_t emb_work_cancel_sync(emb_work_t *work, emb_timeout_t timeout) st = EMB_OK; } else { w->flags &= (uint8_t)~EMBK_WORK_RESUBMITTED; - st = EMB_TIMEOUT_IS_NO_WAIT(timeout) ? EMB_ETIMEDOUT : EMB_OK; + st = nowait ? EMB_ETIMEDOUT : EMB_OK; must_wait = st == EMB_OK; } if (!must_wait) { @@ -421,6 +435,19 @@ emb_status_t emb_work_cancel_sync(emb_work_t *work, emb_timeout_t timeout) return st; } +emb_status_t emb_work_cancel_sync(emb_work_t *work, emb_timeout_t timeout) +{ + embk_work_t *w = work_of(work); + emb_tick_t deadline = embk_deadline_from_timeout(timeout); /* fixed at the call */ + EMBK_API_REQUIRE_THREAD(EMB_API_ID_WORK_CANCEL_SYNC); + if (w == NULL) { + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(embk_cpu.current), 0u, EMB_EINVAL); + EMBK_API_MISUSE(EMB_API_ID_WORK_CANCEL_SYNC, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + } + return embk_work_cancel_sync(EMB_API_ID_WORK_CANCEL_SYNC, w, deadline, + EMB_TIMEOUT_IS_NO_WAIT(timeout)); +} + emb_work_state_t emb_work_state(const emb_work_t *work) { const embk_work_t *w = work_of(work); diff --git a/tests/conformance/CMakeLists.txt b/tests/conformance/CMakeLists.txt index 4c23a47..da22aac 100644 --- a/tests/conformance/CMakeLists.txt +++ b/tests/conformance/CMakeLists.txt @@ -32,6 +32,9 @@ endif() if(CONFIG_EMB_WORKQ) emb_add_test(test_workq test_workq.c) endif() +if(CONFIG_EMB_TIMER) + emb_add_test(test_timer test_timer.c) +endif() emb_add_test(test_first_execution test_first_execution.c) if(CONFIG_EMB_ARCH_HAS_FPU_LAZY) emb_add_test(test_fpu test_fpu.c) diff --git a/tests/conformance/test_timer.c b/tests/conformance/test_timer.c new file mode 100644 index 0000000..50629c3 --- /dev/null +++ b/tests/conformance/test_timer.c @@ -0,0 +1,246 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* Software timer conformance (SPEC-003 §7; KRN-TIM-012 to 016, 028). */ +#include + +#if CONFIG_EMB_ARCH_NATIVE && CONFIG_EMB_TICKLESS +#include +#endif + +static emb_timer_storage_t tm_storage; +static emb_timer_t tm; +#if CONFIG_EMB_WORKQ +static emb_workq_storage_t wq_storage; +EMB_THREAD_STACK(wq_stack, EMB_TEST_STACK_SIZE); +static emb_workq_t wq; +#endif + +#define MAX_AT 8u +static uint32_t at[MAX_AT]; /* when each callback ran, relative to t_base */ +static unsigned runs; +static uint32_t t_base; +static uint32_t sleep_in_callback; +static unsigned stop_after; /* the callback stops its own timer after this many runs */ +static emb_context_t context_seen; + +static void callback(emb_timer_t timer, void *arg) +{ + (void)arg; + if (runs < MAX_AT) { + at[runs] = EMB_TEST_NOW() - t_base; + } + runs++; + context_seen = emb_context(); + emb_test_mark((int)runs); + if (stop_after != 0u && runs == stop_after) { + emb_timer_state_t st = EMB_TIMER_IDLE; + EMB_ASSERT_OK(emb_timer_stop(timer, &st)); + EMB_ASSERT_EQ(st, EMB_TIMER_RUNNING); /* from its own callback */ + } + if (sleep_in_callback != 0u) { + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(sleep_in_callback))); + } +} + +static void clear_runs(void) +{ + runs = 0u; + sleep_in_callback = 0u; + stop_after = 0u; + t_base = EMB_TEST_NOW(); +} + +/* The instant a test starts its timer; on a real-time port a tick may pass between this + * read and the start, so absolute instants are checked as n or n + 1 ticks. */ +#define BEGIN() (t_base = EMB_TEST_NOW()) +#define AT(k, n) emb_test_check_elapsed(__LINE__, at[(k)], (n)) + +static void make(uint8_t flags) +{ + emb_timer_attr_t attr; + clear_runs(); + emb_timer_attr_default(&attr); + attr.fn = callback; + attr.flags = flags; +#if CONFIG_EMB_WORKQ + if ((flags & EMB_TIMER_ISR_CONTEXT) == 0u) { + emb_workq_attr_t qa; + emb_workq_attr_default(&qa); + qa.stack = wq_stack; + qa.stack_size = sizeof(wq_stack); + qa.priority = 2u; /* above the runner: a callback runs as soon as it is queued */ + EMB_ASSERT_OK(emb_workq_init(&wq_storage, &qa, &wq)); + attr.workq = wq; + } +#endif + EMB_ASSERT_OK(emb_timer_init(&tm_storage, &attr, &tm)); +} + +static void teardown(uint8_t flags) +{ + EMB_ASSERT_OK(emb_timer_stop_sync(tm, EMB_WAIT_FOREVER)); + EMB_ASSERT_OK(emb_timer_destroy(tm)); +#if CONFIG_EMB_WORKQ + if ((flags & EMB_TIMER_ISR_CONTEXT) == 0u) { + EMB_ASSERT_OK(emb_workq_destroy(wq)); + } +#endif + (void)flags; +} + +EMB_TEST(timer_isr_context_one_shot_and_periodic_without_drift) +{ + uint32_t exp = 0u; + uint32_t over = 9u; + EMB_TEST_REQ("KRN-TIM-025", "KRN-TIM-005", "KRN-TIM-014", "KRN-TIM-015"); + make(EMB_TIMER_ISR_CONTEXT); + BEGIN(); + EMB_ASSERT_OK(emb_timer_start(tm, EMB_TICKS(3))); + EMB_ASSERT_TRUE(emb_timer_is_running(tm)); + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(5))); + EMB_ASSERT_MARKS(1); + AT(0, 3u); + EMB_ASSERT_EQ(context_seen, EMB_CONTEXT_ISR); /* in the expiry interrupt (KRN-TIM-005) */ + EMB_ASSERT_TRUE(!emb_timer_is_running(tm)); /* one-shot: done */ + clear_runs(); + stop_after = 4u; + BEGIN(); + EMB_ASSERT_OK(emb_timer_start_periodic(tm, EMB_TICKS(2), EMB_TICKS(3))); + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(20))); + EMB_ASSERT_MARKS(1, 1, 2, 3, 4); /* after the one-shot's; the fourth stopped it */ + AT(0, 2u); /* first + k * period: 2, 5, 8, 11 */ + EMB_ASSERT_EQ(at[1] - at[0], 3u); /* the callbacks run in the tick: spacing is exact */ + EMB_ASSERT_EQ(at[2] - at[0], 6u); + EMB_ASSERT_EQ(at[3] - at[0], 9u); + EMB_ASSERT_OK(emb_timer_get_counts(tm, &exp, &over)); + EMB_ASSERT_EQ(exp, 5u); /* the one-shot and four periodic expiries */ + EMB_ASSERT_EQ(over, 0u); + teardown(EMB_TIMER_ISR_CONTEXT); +} + +EMB_TEST(timer_restart_remaining_and_stop_states) +{ + emb_duration_t rem = {0u}; + emb_timer_state_t st = EMB_TIMER_RUNNING; + EMB_TEST_REQ("KRN-TIM-025", "KRN-TIM-028"); + make(EMB_TIMER_ISR_CONTEXT); + EMB_ASSERT_STATUS(emb_timer_restart(tm), EMB_ESTATE); /* never started */ + EMB_ASSERT_OK(emb_timer_stop(tm, &st)); + EMB_ASSERT_EQ(st, EMB_TIMER_IDLE); + EMB_ASSERT_OK(emb_timer_start(tm, EMB_TICKS(10))); + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(4))); + EMB_ASSERT_OK(emb_timer_remaining(tm, &rem)); + emb_test_check_elapsed(__LINE__, 10u - (uint32_t)rem.ticks, 4u); /* 6 left */ + EMB_ASSERT_OK(emb_timer_restart(tm)); /* the same delay, from now */ + EMB_ASSERT_OK(emb_timer_remaining(tm, &rem)); + emb_test_check_elapsed(__LINE__, 10u - (uint32_t)rem.ticks, 0u); /* 10 again */ + EMB_ASSERT_MISUSE(emb_timer_destroy(tm), EMB_EBUSY); /* armed */ + EMB_ASSERT_OK(emb_timer_stop(tm, &st)); + EMB_ASSERT_EQ(st, EMB_TIMER_PENDING); + EMB_ASSERT_OK(emb_timer_remaining(tm, &rem)); + EMB_ASSERT_EQ(rem.ticks, 0u); + EMB_ASSERT_OK(emb_timer_start_at(tm, emb_instant_add(emb_time_now(), EMB_TICKS(2)))); + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(3))); + EMB_ASSERT_MARKS(1); + EMB_ASSERT_MISUSE(emb_timer_start_periodic(tm, EMB_TICKS(1), EMB_TICKS(0)), EMB_EINVAL); + teardown(EMB_TIMER_ISR_CONTEXT); +} + +#if CONFIG_EMB_WORKQ +EMB_TEST(timer_callback_runs_on_its_work_queue) +{ + EMB_TEST_REQ("KRN-TIM-013", "KRN-TIM-005", "KRN-TIM-026"); + make(0u); + BEGIN(); + EMB_ASSERT_OK(emb_timer_start(tm, EMB_TICKS(2))); + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(3))); + EMB_ASSERT_MARKS(1); + AT(0, 2u); /* the queue outranks the runner: it runs at the expiry */ + EMB_ASSERT_EQ(context_seen, EMB_CONTEXT_THREAD); /* on the queue's thread */ + EMB_ASSERT_MISUSE(emb_workq_destroy(wq), EMB_EBUSY); /* a timer names the queue */ + teardown(0u); +} + +EMB_TEST(timer_stop_sync_waits_for_a_running_callback) +{ + emb_timer_state_t st = EMB_TIMER_IDLE; + EMB_TEST_REQ("KRN-TIM-028", "KRN-WAIT-012"); + make(0u); + sleep_in_callback = 5u; + BEGIN(); + EMB_ASSERT_OK(emb_timer_start(tm, EMB_TICKS(1))); + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(2))); /* the callback started and sleeps */ + EMB_ASSERT_MARKS(1); + EMB_ASSERT_OK(emb_timer_stop(tm, &st)); + EMB_ASSERT_EQ(st, EMB_TIMER_RUNNING); + EMB_ASSERT_STATUS(emb_timer_stop_sync(tm, EMB_NO_WAIT), EMB_ETIMEDOUT); + EMB_ASSERT_OK(emb_timer_stop_sync(tm, EMB_WAIT_FOREVER)); + EMB_ASSERT_TRUE(EMB_TEST_NOW() - t_base >= 6u); /* after the callback returned */ + EMB_ASSERT_OK(emb_timer_destroy(tm)); + EMB_ASSERT_OK(emb_workq_destroy(wq)); +} + +EMB_TEST(timer_expiries_during_a_long_callback_are_overruns) +{ + uint32_t exp = 0u; + uint32_t over = 0u; + EMB_TEST_REQ("KRN-TIM-027"); + make(0u); + sleep_in_callback = 2u; + EMB_ASSERT_OK(emb_timer_start_periodic(tm, EMB_TICKS(1), EMB_TICKS(1))); + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(6))); + EMB_ASSERT_OK(emb_timer_stop_sync(tm, EMB_WAIT_FOREVER)); + EMB_ASSERT_OK(emb_timer_get_counts(tm, &exp, &over)); + /* each expiry started a run, was an overrun, or was dropped by the stop */ + EMB_ASSERT_TRUE(runs + over <= exp); + EMB_ASSERT_TRUE(over >= 2u); +#if CONFIG_EMB_ARCH_NATIVE + /* virtual time: runs at 1, 3 and 5 sleep two ticks each; the expiries at 3 and 5 find + * a rerun already due, and the rerun queued at 6 is dropped by the stop */ + EMB_ASSERT_EQ(exp, 6u); + EMB_ASSERT_EQ(runs, 3u); + EMB_ASSERT_EQ(over, 2u); +#endif + EMB_ASSERT_OK(emb_timer_destroy(tm)); + EMB_ASSERT_OK(emb_workq_destroy(wq)); +} +#endif + +EMB_TEST(timer_init_needs_a_queue_or_isr_context) +{ + emb_timer_attr_t attr; + emb_timer_t other; + EMB_TEST_REQ("KRN-TIM-013", "API-015"); + emb_timer_attr_default(&attr); + EMB_ASSERT_MISUSE(emb_timer_init(&tm_storage, &attr, &other), EMB_EINVAL); /* no callback */ + attr.fn = callback; + EMB_ASSERT_STATUS(emb_timer_init(&tm_storage, &attr, &other), EMB_ENOTSUP); /* no queue */ + attr.flags = 0x80u; + EMB_ASSERT_MISUSE(emb_timer_init(&tm_storage, &attr, &other), EMB_EINVAL); /* unknown flag */ +} + +#if CONFIG_EMB_ARCH_NATIVE && CONFIG_EMB_TICKLESS +EMB_TEST(timer_missed_periods_are_skipped_in_closed_form) +{ + uint32_t exp = 0u; + uint32_t over = 0u; + emb_duration_t rem = {0u}; + emb_irq_key_t key; + unsigned i; + EMB_TEST_REQ("KRN-TIM-015", "KRN-TIM-027"); + make(EMB_TIMER_ISR_CONTEXT); + EMB_ASSERT_OK(emb_timer_start_periodic(tm, EMB_TICKS(2), EMB_TICKS(2))); + key = emb_irq_lock(); /* the clock runs on while the expiry interrupt waits */ + for (i = 0u; i < 9u; i++) { + emb_native_tick(); + } + emb_irq_unlock(key); /* one pass at 9: due at 2, then 4, 6 and 8 missed */ + EMB_ASSERT_MARKS(1); + EMB_ASSERT_OK(emb_timer_get_counts(tm, &exp, &over)); + EMB_ASSERT_EQ(exp, 1u); + EMB_ASSERT_EQ(over, 3u); + EMB_ASSERT_OK(emb_timer_remaining(tm, &rem)); + EMB_ASSERT_EQ(rem.ticks, 1u); /* re-armed at 10: still on the 2 + 2k grid */ + teardown(EMB_TIMER_ISR_CONTEXT); +} +#endif diff --git a/tests/differential/diff_runner.c b/tests/differential/diff_runner.c index 7c748a1..92a4f8c 100644 --- a/tests/differential/diff_runner.c +++ b/tests/differential/diff_runner.c @@ -17,6 +17,7 @@ * Q=[,fifo][,bind=.] a message queue of 32-bit items * W=, a work queue whose thread the trace calls tid * H:;... a work item and its handler's operations + * R=isr|q:;... a timer and its callback's operations * I:;... an ISR body (give, resume, nset, start, eset, eclear, * and qsend, qfront, qrecv, qpeek with no wait) * Operations (t: -1 forever, 0 no wait, n ticks): @@ -26,7 +27,7 @@ * tdestroy tid | eset e bits | eclear e bits | ewait e mask all clear t | edestroy e | * cwait c m t | csignal c | cbcast c | cdestroy c | qsend q value t | qfront q value | * qrecv q t | qpeek q | qdestroy q | submit q w | wcancel w | wcsync w t | wflush q t | - * wqdestroy q + * wqdestroy q | tstart t first period | tstop t | tsync t t * A schedule entry fires right after the n-th trace event (or in idle, if that event never * comes): "isr@n" raises ISR i, "tick@n" advances virtual time by one tick. */ @@ -59,6 +60,7 @@ #define MAX_MSGQ_CAP 8u #define MAX_WORKQS 2u #define MAX_WORKS 4u +#define MAX_TIMERS 2u #define MAX_ISRS 4u #define MAX_EVENTS 16384u #define MAX_SCHED 64u @@ -109,7 +111,10 @@ enum op_kind { OP_WCANCEL, OP_WCSYNC, OP_WFLUSH, - OP_WQDESTROY + OP_WQDESTROY, + OP_TSTART, + OP_TSTOP, + OP_TSYNC }; struct op { @@ -201,6 +206,17 @@ struct work_desc { #endif }; +struct timer_desc { + long t; + bool isr; + long q; + struct program body; +#if CONFIG_EMB_TIMER + emb_timer_storage_t storage; + emb_timer_t handle; +#endif +}; + struct mutex_desc { long mid; long protocol; @@ -239,6 +255,8 @@ static struct workq_desc workqs[MAX_WORKQS]; static unsigned workq_count; static struct work_desc works[MAX_WORKS]; static unsigned work_count; +static struct timer_desc timers[MAX_TIMERS]; +static unsigned timer_count; #if CONFIG_EMB_WORKQ EMB_THREAD_STACK(workq_stacks[MAX_WORKQS], STACK_SIZE); #endif @@ -432,6 +450,18 @@ static void parse_op(char *text, struct op *op) } else if (strcmp(name, "wcancel") == 0 || strcmp(name, "wqdestroy") == 0) { op->kind = (name[1] == 'c') ? OP_WCANCEL : OP_WQDESTROY; op->a = num(args[0]); + } else if (strcmp(name, "tstart") == 0) { + op->kind = OP_TSTART; + op->a = num(args[0]); + op->b = num(args[1]); + op->c = num(args[2]); + } else if (strcmp(name, "tstop") == 0) { + op->kind = OP_TSTOP; + op->a = num(args[0]); + } else if (strcmp(name, "tsync") == 0) { + op->kind = OP_TSYNC; + op->a = num(args[0]); + op->b = num(args[1]); } else { die("unknown operation"); } @@ -567,6 +597,18 @@ static void parse_section(char *sec) w = &works[work_count++]; w->w = num(cut(&p, ':')); parse_program(p, &w->body); + } else if (kind == 'R') { + struct timer_desc *t; + char *mode; + if (timer_count >= MAX_TIMERS) { + die("too many timers"); + } + t = &timers[timer_count++]; + t->t = num(cut(&p, '=')); + mode = cut(&p, ':'); + t->isr = strcmp(mode, "isr") == 0; + t->q = t->isr ? -1 : num(mode + 1); + parse_program(p, &t->body); } else if (kind == 'M') { struct mutex_desc *m; char *attrs; @@ -717,6 +759,20 @@ static emb_work_t *work_by_w(long w) } #endif +#if CONFIG_EMB_TIMER +static emb_timer_t timer_by_t(long t) +{ + unsigned i; + for (i = 0u; i < timer_count; i++) { + if (timers[i].t == t) { + return timers[i].handle; + } + } + die("unknown timer id"); + return timers[0].handle; +} +#endif + static emb_mutex_t mutex_by_mid(long mid) { unsigned i; @@ -1022,6 +1078,29 @@ static void run_ops(const struct program *prog, struct thread_desc *self) case OP_WQDESTROY: die("work queues are not configured"); break; +#endif +#if CONFIG_EMB_TIMER + case OP_TSTART: + st = (op->c == 0) + ? emb_timer_start(timer_by_t(op->a), EMB_TICKS((emb_tick_t)op->b)) + : emb_timer_start_periodic(timer_by_t(op->a), EMB_TICKS((emb_tick_t)op->b), + EMB_TICKS((emb_tick_t)op->c)); + break; + case OP_TSTOP: { + emb_timer_state_t was = EMB_TIMER_IDLE; + st = emb_timer_stop(timer_by_t(op->a), &was); + data = was; + break; + } + case OP_TSYNC: + st = emb_timer_stop_sync(timer_by_t(op->a), timeout_of(op->b)); + break; +#else + case OP_TSTART: + case OP_TSTOP: + case OP_TSYNC: + die("software timers are not configured"); + break; #endif default: die("bad operation kind"); @@ -1040,6 +1119,14 @@ static void thread_entry(void *arg) run_ops(&((struct thread_desc *)arg)->prog, (struct thread_desc *)arg); } +#if CONFIG_EMB_TIMER +static void timer_callback(emb_timer_t timer, void *arg) +{ + (void)timer; + run_ops(&((struct timer_desc *)arg)->body, NULL); /* results unrecorded */ +} +#endif + #if CONFIG_EMB_WORKQ static void work_handler(emb_work_t *work) { @@ -1065,7 +1152,7 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) } dumped = 1u; (void)printf("config profile=%s owner_death=%s checked=%d tickless=%d idle_index=254 events=%d " - "conds=%d msgqs=%d workqs=%d\n", + "conds=%d msgqs=%d workqs=%d timers=%d\n", CONFIG_EMB_PROFILE, #if CONFIG_EMB_MUTEX_OWNER_DEATH_FAULT && CONFIG_EMB_CHECKED "fault", @@ -1073,7 +1160,8 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) "release", #endif (int)CONFIG_EMB_CHECKED, (int)CONFIG_EMB_TICKLESS, (int)CONFIG_EMB_EVENT, - (int)CONFIG_EMB_COND, (int)CONFIG_EMB_MSGQ, (int)CONFIG_EMB_WORKQ); + (int)CONFIG_EMB_COND, (int)CONFIG_EMB_MSGQ, (int)CONFIG_EMB_WORKQ, + (int)CONFIG_EMB_TIMER); n = event_count; for (i = 0u; i < n; i++) { (void)printf("ev %u %u %lu %lu %lu %lu\n", (unsigned)events[i].id, @@ -1106,6 +1194,15 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) (unsigned long)emb_event_get(evflags[i].handle)); } #endif +#if CONFIG_EMB_TIMER + for (i = 0u; i < timer_count; i++) { + uint32_t exp = 0u; + uint32_t over = 0u; + (void)emb_timer_get_counts(timers[i].handle, &exp, &over); + (void)printf("timer %ld %lu %lu %d\n", timers[i].t, (unsigned long)exp, (unsigned long)over, + emb_timer_is_running(timers[i].handle) ? 1 : 0); + } +#endif #if CONFIG_EMB_WORKQ for (i = 0u; i < work_count; i++) { (void)printf("work %ld %u\n", works[i].w, (unsigned)emb_work_state(&works[i].work)); @@ -1245,6 +1342,28 @@ static void construct(void) if (workq_count != 0u || work_count != 0u) { die("work queues are not configured"); } +#endif +#if CONFIG_EMB_TIMER + for (i = 0u; i < timer_count; i++) { + emb_timer_attr_t attr; + emb_timer_attr_default(&attr); + attr.fn = timer_callback; + attr.arg = &timers[i]; + if (timers[i].isr) { + attr.flags = EMB_TIMER_ISR_CONTEXT; + } else { +#if CONFIG_EMB_WORKQ + attr.workq = workq_by_q(timers[i].q); +#else + die("work queue timers are not configured"); +#endif + } + EMB_CHECK(emb_timer_init(&timers[i].storage, &attr, &timers[i].handle)); + } +#else + if (timer_count != 0u) { + die("software timers are not configured"); + } #endif for (i = 0u; i < mutex_count; i++) { emb_mutex_attr_t attr; diff --git a/tools/fault/api-ids.json b/tools/fault/api-ids.json index c108e87..4ce2df8 100644 --- a/tools/fault/api-ids.json +++ b/tools/fault/api-ids.json @@ -745,6 +745,78 @@ "name": "emb_work_state", "header": "work.h", "state": "active" + }, + { + "id": 125, + "name": "emb_timer_attr_default", + "header": "timer.h", + "state": "active" + }, + { + "id": 126, + "name": "emb_timer_init", + "header": "timer.h", + "state": "active" + }, + { + "id": 127, + "name": "emb_timer_destroy", + "header": "timer.h", + "state": "active" + }, + { + "id": 128, + "name": "emb_timer_start", + "header": "timer.h", + "state": "active" + }, + { + "id": 129, + "name": "emb_timer_start_at", + "header": "timer.h", + "state": "active" + }, + { + "id": 130, + "name": "emb_timer_start_periodic", + "header": "timer.h", + "state": "active" + }, + { + "id": 131, + "name": "emb_timer_restart", + "header": "timer.h", + "state": "active" + }, + { + "id": 132, + "name": "emb_timer_stop", + "header": "timer.h", + "state": "active" + }, + { + "id": 133, + "name": "emb_timer_stop_sync", + "header": "timer.h", + "state": "active" + }, + { + "id": 134, + "name": "emb_timer_is_running", + "header": "timer.h", + "state": "active" + }, + { + "id": 135, + "name": "emb_timer_remaining", + "header": "timer.h", + "state": "active" + }, + { + "id": 136, + "name": "emb_timer_get_counts", + "header": "timer.h", + "state": "active" } ] } diff --git a/tools/fault/api-registry.json b/tools/fault/api-registry.json index f11d03b..d6b898f 100644 --- a/tools/fault/api-registry.json +++ b/tools/fault/api-registry.json @@ -744,6 +744,78 @@ "name": "emb_work_state", "header": "work.h", "state": "active" + }, + { + "id": 125, + "name": "emb_timer_attr_default", + "header": "timer.h", + "state": "active" + }, + { + "id": 126, + "name": "emb_timer_init", + "header": "timer.h", + "state": "active" + }, + { + "id": 127, + "name": "emb_timer_destroy", + "header": "timer.h", + "state": "active" + }, + { + "id": 128, + "name": "emb_timer_start", + "header": "timer.h", + "state": "active" + }, + { + "id": 129, + "name": "emb_timer_start_at", + "header": "timer.h", + "state": "active" + }, + { + "id": 130, + "name": "emb_timer_start_periodic", + "header": "timer.h", + "state": "active" + }, + { + "id": 131, + "name": "emb_timer_restart", + "header": "timer.h", + "state": "active" + }, + { + "id": 132, + "name": "emb_timer_stop", + "header": "timer.h", + "state": "active" + }, + { + "id": 133, + "name": "emb_timer_stop_sync", + "header": "timer.h", + "state": "active" + }, + { + "id": 134, + "name": "emb_timer_is_running", + "header": "timer.h", + "state": "active" + }, + { + "id": 135, + "name": "emb_timer_remaining", + "header": "timer.h", + "state": "active" + }, + { + "id": 136, + "name": "emb_timer_get_counts", + "header": "timer.h", + "state": "active" } ] } diff --git a/tools/model/README.md b/tools/model/README.md index 0bab088..11491aa 100644 --- a/tools/model/README.md +++ b/tools/model/README.md @@ -10,7 +10,7 @@ The model is written so that it can be read next to the specification: `embmodel |---|---| | `embmodel/kernel.py` | State (`Thread`, `WaitQueue`, `Semaphore`, `Mutex`, `Kernel`), the static op types (`Take`, `Give`, `Lock`, `Unlock`, `NotifySet`, `NotifyWait`, `Start`, `Join`, `Sleep`, `Yield`, `SetPrio`, `Suspend`, `Resume`, `Cancel`, `Destroy`, `SchedLock`, `SchedUnlock`, `Exit`), the wait protocol, effective priority with `effective()` and `propagate()`, interrupts and ticks, invariants | | `embmodel/explore.py` | Exhaustive depth-first exploration of every interleaving at section granularity; seeded random walks | -| `tests/scenarios.py` | The scenario catalogue: the SPEC-004 §15 worked example and the wait-protocol scenarios (`ALL`), plus the SPEC-005 §15 mutex scenarios (`MUTEX`): classic inversion, nested chain with timeouts, deadlock in both orders, owner death with and without a waiter, ceiling, waiter priority change, cancellation of a waiter, recursion, two-waiter hand-off; plus the SPEC-006 §11 notification scenarios (`NOTIFY`): set in the window, level semantics, ALL across two sets, one thread bound to two semaphores; plus the SPEC-008 §15 lifecycle scenarios (`JOIN`): join before and after exit, join with timeout, second joiner refused, canceled joiner, start of an inactive thread, exit while owning a mutex with a joiner; plus the binding lifetime scenarios (`BINDING`, ADR-046), event flags (`EVENT`, SPEC-005 §5), condition variables (`COND`, SPEC-005 §6) and message queues (`MSGQ`, SPEC-007 §2, modeled before their implementation per KRN-IPC-022): FIFO order with a full queue, receiver hand-off, send_front and peek, ISR send and receive with `EMB_NO_WAIT`, sender timeout and cancellation, destroy of both sides, the empty-to-nonempty binding; plus work queues (`WORKQ`, SPEC-006 §4), whose queue thread drains a FIFO of items whose handlers run model operations: FIFO order and idempotent submit, resubmission while running, cancel states, flush with a timeout and while a handler runs, ISR submit, destroy joining the queue thread | +| `tests/scenarios.py` | The scenario catalogue: the SPEC-004 §15 worked example and the wait-protocol scenarios (`ALL`), plus the SPEC-005 §15 mutex scenarios (`MUTEX`): classic inversion, nested chain with timeouts, deadlock in both orders, owner death with and without a waiter, ceiling, waiter priority change, cancellation of a waiter, recursion, two-waiter hand-off; plus the SPEC-006 §11 notification scenarios (`NOTIFY`): set in the window, level semantics, ALL across two sets, one thread bound to two semaphores; plus the SPEC-008 §15 lifecycle scenarios (`JOIN`): join before and after exit, join with timeout, second joiner refused, canceled joiner, start of an inactive thread, exit while owning a mutex with a joiner; plus the binding lifetime scenarios (`BINDING`, ADR-046), event flags (`EVENT`, SPEC-005 §5), condition variables (`COND`, SPEC-005 §6) and message queues (`MSGQ`, SPEC-007 §2, modeled before their implementation per KRN-IPC-022): FIFO order with a full queue, receiver hand-off, send_front and peek, ISR send and receive with `EMB_NO_WAIT`, sender timeout and cancellation, destroy of both sides, the empty-to-nonempty binding; plus work queues (`WORKQ`, SPEC-006 §4), whose queue thread drains a FIFO of items whose handlers run model operations: FIFO order and idempotent submit, resubmission while running, cancel states, flush with a timeout and while a handler runs, ISR submit, destroy joining the queue thread; plus software timers (`TIMER`, SPEC-003 §7): one-shot and periodic ISR-context timers, a thread timeout and a timer due together, a work-queue callback, stop of a running callback and `stop_sync`, overruns from a long callback. A scenario `horizon` stops time from advancing while a thread could run once a periodic timer has run that long, as the runner does | | `tests/test_scenarios.py` | Specific interleavings with expected results | | `tests/test_explore.py` | Exhaustive exploration of every scenario plus outcome properties | | `tests/test_random.py` | Random walks over generated scenarios; a Hypothesis-generated scenario/seed campaign when installed | @@ -56,7 +56,7 @@ CTest registers this as `differential_timer_regressions` on applicable native bu ## What is checked -After every action: invariants 1 to 6 of SPEC-004 §5.4 (queue membership matches the wait state; a thread is in the ready structure exactly when runnable and not current; PRIORITY_FIFO queues are ordered; at most one dequeue per wait generation; an armed timeout belongs to a waiting thread or to the open window; the result is set before READY), semaphore unit conservation (gives equal count plus units handed to waiters plus units taken immediately), and the SPEC-005 invariants (every thread's effective priority equals `effective(base, owned)`; an inheriting mutex's owner is at least as high as every waiter on it; a ceiling mutex's owner is at least at the ceiling; an owner is never in its own queue; owned lists and owner fields agree; a free mutex has no waiters), and the message queue invariants (receivers wait only while the ring is empty, senders only while it is full, the ring never exceeds its capacity, and every accepted item is received once, still stored or dropped by destroy), and the work queue invariants (an item is queued exactly when it is in exactly one FIFO, running exactly when it is its queue's running item, resubmitted only while running, and every completion waiter awaits a running item or a queued flush marker). At every terminal state: no runnable thread is left undispatched, no timeout is left armed for a blocked thread, and no blocked thread sits on an undetected inheritance cycle. +After every action: invariants 1 to 6 of SPEC-004 §5.4 (queue membership matches the wait state; a thread is in the ready structure exactly when runnable and not current; PRIORITY_FIFO queues are ordered; at most one dequeue per wait generation; an armed timeout belongs to a waiting thread or to the open window; the result is set before READY), semaphore unit conservation (gives equal count plus units handed to waiters plus units taken immediately), and the SPEC-005 invariants (every thread's effective priority equals `effective(base, owned)`; an inheriting mutex's owner is at least as high as every waiter on it; a ceiling mutex's owner is at least at the ceiling; an owner is never in its own queue; owned lists and owner fields agree; a free mutex has no waiters), and the message queue invariants (receivers wait only while the ring is empty, senders only while it is full, the ring never exceeds its capacity, and every accepted item is received once, still stored or dropped by destroy), and the work queue invariants (an item is queued exactly when it is in exactly one FIFO, running exactly when it is its queue's running item, resubmitted only while running, and every completion waiter awaits a running item or a queued flush marker), and the timer invariants (a periodic deadline stays on its first expiry's grid; no timer keeps a pass marker after its pass). At every terminal state: no runnable thread is left undispatched, no timeout is left armed for a blocked thread, and no blocked thread sits on an undetected inheritance cycle. ## Extending diff --git a/tools/model/bridge.py b/tools/model/bridge.py index 76c8091..d981d8e 100644 --- a/tools/model/bridge.py +++ b/tools/model/bridge.py @@ -32,9 +32,10 @@ Destroy, EventClear, EventDestroy, EventSet, EventWait, Exit, Give, Join, Lock, ModelError, MsgDestroy, MsgPeek, MsgRecv, MsgSend, MsgSendFront, Submit, WorkCancel, WorkCancelSync, WorkFlush, WorkqDestroy, + TimerStart, TimerStop, TimerStopSync, NONWAIT_RESULT_OPS, NotifySet, NotifyWait, Policy, Reason, Result, Resume, Scenario, SchedLock, SchedUnlock, SetPrio, Sleep, Start, Suspend, Take, ThreadDestroy, - UnbindNotify, Unlock, Yield, build, FOREVER, NO_WAIT) + UnbindNotify, Unlock, Yield, build, FOREVER, NO_WAIT, TIMER_WORK_BASE) from tests import scenarios as catalogue # noqa: E402 # ---- the kernel's trace vocabulary (include/emb/trace.h, M1 subset) ------------------------ @@ -46,7 +47,8 @@ TR_EVENT_SET, TR_EVENT_WAIT = 31, 32 TR_COND_WAIT, TR_COND_SIGNAL = 33, 34 TR_MSGQ_SEND, TR_MSGQ_RECEIVE = 35, 36 -TR_WORK_WAIT = 40 +TR_WORK_RUN, TR_WORK_WAIT = 38, 40 +TR_TIMER_EXPIRE, TR_TIMER_START, TR_TIMER_STOP = 41, 42, 43 CTX_THREAD = 0 # emb_context_t EV_ISR_MARK = 1000 # the runner's own record: ISR body i starts EV_INDEX_MARK = 1001 # the runner's record: thread tid now has index i @@ -178,6 +180,12 @@ def encode_op(op) -> str: return f"wflush {op.q} {_t(op.timeout)}" if isinstance(op, WorkqDestroy): return f"wqdestroy {op.q}" + if isinstance(op, TimerStart): + return f"tstart {op.timer} {op.first} {op.period}" + if isinstance(op, TimerStop): + return f"tstop {op.timer}" + if isinstance(op, TimerStopSync): + return f"tsync {op.timer} {_t(op.timeout)}" raise ValueError(f"unknown op {op!r}") @@ -216,6 +224,9 @@ def encode_scenario(sc: Scenario) -> str: parts.append(f"W{q}={cfg['tid']},{cfg['prio']}") for w, cfg in sc.works.items(): parts.append(f"H{w}:" + ";".join(encode_op(op) for op in cfg.get("body", ()))) + for t, cfg in sc.timers.items(): + mode = "isr" if cfg.get("isr") else f"q{cfg['q']}" + parts.append(f"R{t}={mode}:" + ";".join(encode_op(op) for op in cfg.get("body", ()))) for mid, cfg in sc.mutexes.items(): proto = int(cfg.get("protocol", 0)) parts.append(f"M{mid}={proto},{cfg.get('ceiling', 0)},{int(bool(cfg.get('recursive', False)))}") @@ -237,6 +248,7 @@ def __init__(self): self.evflags = {} # eid -> bits self.msgqs = {} # qid -> stored items, head first self.works = {} # w -> emb_work_state_t + self.timers = {} # t -> (expiries, overruns, armed) self.end = None # ("done",) or ("fault", class, code) self.events_lost = 0 self.exit_code = 0 @@ -306,6 +318,8 @@ def parse_run(stdout: str, stderr: str = "", exit_code: int = 0) -> Run: r.msgqs[int(f[1])] = tuple(int(x) for x in f[2:]) elif f[0] == "work": r.works[int(f[1])] = int(f[2]) + elif f[0] == "timer": + r.timers[int(f[1])] = (int(f[2]), int(f[3]), f[4] == "1") elif f[0] == "events_lost": r.events_lost = int(f[1]) elif f[0] == "end": @@ -359,6 +373,12 @@ def thr(idx): out.append(("wait_end", thr(a0), STATUS_TO_RESULT[st])) elif eid == TR_NOTIFY_SET: out.append(("notify_set", thr(a0), a1)) + elif eid in (TR_TIMER_START, TR_TIMER_EXPIRE): # the only record of a silent start + out.append(("timer_start" if eid == TR_TIMER_START else "timer_expire", ("t", a1))) + elif eid == TR_TIMER_STOP: # and of what a stop found + out.append(("timer_stop", ("t", a1), a2)) + elif eid == TR_WORK_RUN: # a handler's start (0) and end (1) + out.append(("work_run", ("w", a1), a2)) elif eid == TR_MUTEX_UNLOCK: out.append(("mutex_unlock", thr(a0), ("m", a1), None if a2 == 0xFF else thr(a2))) elif eid == TR_THREAD_EXIT: @@ -420,6 +440,12 @@ def model_canonical(cur, trace, act) -> list: out.append(("thread_exit", ev[1], ev[2])) elif name == "thread_start": out.append(("thread_start", ev[1])) + elif name in ("timer_start", "timer_expire"): + out.append((name, ("t", ev[1]))) + elif name == "timer_stop": + out.append((name, ("t", ev[1]), ev[2])) + elif name == "work_run": + out.append((name, ("w", ev[1]), ev[2])) # mutex_lock, notify_dropped, stale_timeout: no kernel counterpart i += 1 return out @@ -463,6 +489,16 @@ def match(self, kev, mev, qmap, mmap) -> bool: return False mmap[km] = mm return True + if kev[0] in ("timer_start", "timer_expire", "timer_stop", "work_run"): + if kev[2:] != mev[2:]: + return False # a stop's state, a run's phase + kt, mt = (kev[1][0], kev[1][1]), mev[1] # learned like a queue address, tagged + if kt in qmap: + return qmap[kt] == mt + if mt in qmap.values(): + return False + qmap[kt] = mt + return True if kev[0] == "tick": return True # the tick count is compared through the events it causes return kev[1:] == mev[1:] @@ -620,7 +656,8 @@ def finish(self, k, path): kres_mapped = Result.TIMEOUT # a completed sleep is EMB_OK in the kernel if kres_mapped != result: raise Divergence(f"thread {tid} op {op_index} {op!r}: model {result.name}, kernel status {kstatus}") - if isinstance(op, (NotifyWait, Join, Lock, EventWait, CondWait, MsgRecv, MsgPeek, WorkCancel)) \ + if isinstance(op, (NotifyWait, Join, Lock, EventWait, CondWait, MsgRecv, MsgPeek, WorkCancel, + TimerStop)) \ and result == Result.SATISFIED \ and kdata != data: raise Divergence(f"thread {tid} op {op_index} {op!r}: model data {data}, kernel data {kdata}") @@ -643,7 +680,13 @@ def finish(self, k, path): for eid, e in k.events.items(): if eid in r.evflags and not e.destroyed and r.evflags[eid] != e.bits: raise Divergence(f"event flags {eid}: kernel bits {r.evflags[eid]:#x}, model {e.bits:#x}") + for tm, t in k.timers.items(): + if r.timers.get(tm) != (t.expiries, t.overruns, t.deadline is not None): + raise Divergence(f"timer {tm}: kernel (expiries, overruns, armed) {r.timers.get(tm)}, " + f"model {(t.expiries, t.overruns, t.deadline is not None)}") for wid, w in k.works.items(): + if wid >= TIMER_WORK_BASE: + continue # a timer's embedded item: the timer line covers it if r.works.get(wid) != w.state: raise Divergence(f"work item {wid}: kernel state {r.works.get(wid)}, model {w.state}") for qid, q in k.msgqs.items(): @@ -709,6 +752,8 @@ def applicable(sc: Scenario, config: dict) -> tuple: return False, "message queues are not configured in this build" if sc.workqs and config.get("workqs", "0") != "1": return False, "work queues are not configured in this build" + if sc.timers and config.get("timers", "0") != "1": + return False, "software timers are not configured in this build" if profile == "tiny": if len(set(prios)) != len(prios): return False, "shared priorities need the base profile" diff --git a/tools/model/embmodel/__init__.py b/tools/model/embmodel/__init__.py index 2a1dd74..1228c4d 100644 --- a/tools/model/embmodel/__init__.py +++ b/tools/model/embmodel/__init__.py @@ -11,6 +11,7 @@ from .kernel import ( # noqa: F401 BindNotify, Cancel, CondBroadcast, MsgDestroy, MsgPeek, MsgRecv, MsgSend, MsgSendFront, Submit, WorkCancel, WorkCancelSync, WorkFlush, WorkqDestroy, K_WORK, WORK_IDLE, WORK_PENDING, WORK_RUNNING, + TimerStart, TimerStop, TimerStopSync, TIMER_WORK_BASE, NONWAIT_DATA_OPS, CondDestroy, CondSignal, CondWait, Destroy, EventClear, EventDestroy, EventSet, EventWait, Exit, Give, Join, NONWAIT_RESULT_OPS, ThreadDestroy, UnbindNotify, Kernel, Lock, ModelError, Mutex, NotifySet, NotifyWait, Policy, Protocol, Reason, Result, Resume, Start, SchedLock, SchedUnlock, Scenario, SetPrio, Sleep, Suspend, Take, Thread, diff --git a/tools/model/embmodel/kernel.py b/tools/model/embmodel/kernel.py index af95701..43a59de 100644 --- a/tools/model/embmodel/kernel.py +++ b/tools/model/embmodel/kernel.py @@ -68,6 +68,7 @@ class Protocol(IntEnum): # SPEC-005 §3 MSGQ_QID_BASE = 400 # message queues: receivers 400 + 2i, senders 401 + 2i WORK_QID_BASE = 500 # work queues: the completion queue of queue q is 500 + q K_WORK = 1 << 31 # EMB_NOTIFY_K_WORK with 32-bit notification words (SPEC-006 §2.1) +TIMER_WORK_BASE = 1000 # the work item embedded in timer t is item 1000 + t WORK_IDLE, WORK_PENDING, WORK_RUNNING = 0, 1, 2 # emb_work_state_t; PENDING is "queued" @@ -337,6 +338,27 @@ class WorkDone: q: int +@dataclass(frozen=True) +class TimerStart: + """emb_timer_start(t, first), or emb_timer_start_periodic(t, first, period) when period > 0.""" + timer: int + first: int + period: int = 0 + + +@dataclass(frozen=True) +class TimerStop: + """emb_timer_stop(t): disarm and drop a queued callback; the result's data is the state found.""" + timer: int + + +@dataclass(frozen=True) +class TimerStopSync: + """emb_timer_stop_sync(t, timeout): stop in one section, then wait for a running callback.""" + timer: int + timeout: Optional[int] = FOREVER + + def event_satisfying(bits: int, mask: int, all_: bool) -> int: got = bits & mask if all_: @@ -344,10 +366,10 @@ def event_satisfying(bits: int, mask: int, all_: bool) -> int: return got -NONWAIT_RESULT_OPS = NONWAIT_RESULT_OPS + (Submit,) +NONWAIT_RESULT_OPS = NONWAIT_RESULT_OPS + (Submit, TimerStart) NONWAIT_DATA_OPS = (MsgSendFront, MsgPeek) # record a result and data, never wait ISR_SAFE_OPS = (Give, Resume, NotifySet, Start, EventSet, EventClear, # SPEC-001 §5.2: signal-type operations are `thread isr` - MsgSendFront, MsgPeek, MsgSend, MsgRecv, Submit, WorkCancel) + MsgSendFront, MsgPeek, MsgSend, MsgRecv, Submit, WorkCancel, TimerStart, TimerStop) ISR_NO_WAIT_ONLY = (MsgSend, MsgRecv) # with EMB_NO_WAIT only (SPEC-007 §1) @@ -390,6 +412,7 @@ class Thread: msg_value: int = 0 # a blocked sender's item (the kernel keeps its pointer) wq_item: Optional[int] = None # a queue thread: the item whose handler it runs work_target: Optional[tuple] = None # a completion wait: ("w", item) or ("m", flusher tid) + stop_phase: int = 0 # TimerStopSync: 1 once the stop section ran cancel_deferred: bool = False # cancellation is not delivered (the relock, A41) def key(self): @@ -401,7 +424,7 @@ def key(self): self.notify_clear, tuple(self.owned), self.inactive, self.exit_code, self.joiner, self.collected, self.destroyed, self.cond_phase, None if self.cond_result is None else int(self.cond_result), self.cond_data, - self.cancel_deferred, self.msg_value, self.wq_item, self.work_target) + self.cancel_deferred, self.msg_value, self.wq_item, self.work_target, self.stop_phase) @dataclass @@ -484,6 +507,25 @@ def key(self): return (self.wid, self.state, self.queue, self.resubmitted, self.runs) +@dataclass +class Timer: + tmid: int + isr: bool # EMB_TIMER_ISR_CONTEXT, else its work item runs it on queue q + q: Optional[int] = None + deadline: Optional[int] = None # armed when set + seq: int = 0 # arming order among equal deadlines + period: int = 0 + origin: int = 0 # the first deadline of a periodic start: deadlines stay on its grid + expiries: int = 0 + overruns: int = 0 + armed_in_pass: bool = False # armed by this expiry pass: the next pass expires it + in_callback: bool = False # an ISR_CONTEXT callback runs + + def key(self): + return (self.tmid, self.deadline, self.seq, self.period, self.origin, self.expiries, self.overruns, + self.armed_in_pass, self.in_callback) + + @dataclass class Workq: q: int @@ -525,6 +567,8 @@ class Scenario: msgqs: dict = field(default_factory=dict) # qid -> dict(capacity=, policy=, binding=) workqs: dict = field(default_factory=dict) # q -> dict(tid=, prio=): the queue thread works: dict = field(default_factory=dict) # w -> dict(body=ops the handler runs) + timers: dict = field(default_factory=dict) # t -> dict(isr=bool, q=queue, body=callback ops) + horizon: Optional[int] = None # no tick past it while a periodic timer is armed isrs: tuple = () owner_death: str = "release" # CONFIG_EMB_MUTEX_OWNER_DEATH: "release" | "fault" inactive: tuple = () # tids created INACTIVE (must be started by another thread or an ISR) @@ -534,7 +578,7 @@ class Scenario: def build(sc: Scenario) -> "Kernel": k = Kernel(programs={tid: tuple(p) for tid, (_, p) in sc.threads.items()}, isr_bodies=tuple(sc.isrs), - owner_death=sc.owner_death, tickless=sc.tickless) + owner_death=sc.owner_death, tickless=sc.tickless, horizon=sc.horizon) for tid, (prio, _) in sc.threads.items(): k.threads[tid] = Thread(tid=tid, prio=prio, base_prio=prio, inactive=(tid in sc.inactive)) k.queues[-(tid + 1)] = WaitQueue(qid=-(tid + 1), policy=Policy.FIFO) # SLEEP / NOTIFY / START @@ -560,6 +604,13 @@ def build(sc: Scenario) -> "Kernel": for w, cfg in sc.works.items(): k.works[w] = Work(wid=w) k.work_bodies[w] = tuple(cfg.get("body", ())) + for tm, cfg in sc.timers.items(): + isr = bool(cfg.get("isr", False)) + k.timers[tm] = Timer(tmid=tm, isr=isr, q=None if isr else cfg["q"]) + k.timer_bodies[tm] = tuple(cfg.get("body", ())) + if not isr: # the embedded work item + k.works[TIMER_WORK_BASE + tm] = Work(wid=TIMER_WORK_BASE + tm) + k.work_bodies[TIMER_WORK_BASE + tm] = k.timer_bodies[tm] for mq, cfg in sc.msgqs.items(): rq, wq = MSGQ_QID_BASE + 2 * mq, MSGQ_QID_BASE + 2 * mq + 1 k.queues[rq] = WaitQueue(qid=rq, policy=cfg.get("policy", Policy.PRIORITY_FIFO)) @@ -615,6 +666,8 @@ class Kernel: works: dict = field(default_factory=dict) wq_of_tid: dict = field(default_factory=dict) # static: queue thread tid -> queue work_bodies: dict = field(default_factory=dict) # static: item -> handler ops + timers: dict = field(default_factory=dict) + timer_bodies: dict = field(default_factory=dict) # static: timer -> callback ops qid_mutex: dict = field(default_factory=dict) # static map, not part of the key ready: dict = field(default_factory=dict) current: Optional[int] = None @@ -630,6 +683,8 @@ class Kernel: isr_fired: frozenset = frozenset() pi_depth_max: int = 0 trace: list = field(default_factory=list) + in_expiry: bool = False # transient: inside a timer's expiry, not part of the key + horizon: Optional[int] = None # static: the scenario's tick bound for periodic timers # ------------------------------------------------------------------ state identity def key(self): @@ -642,6 +697,7 @@ def key(self): tuple(q.key() for _, q in sorted(self.msgqs.items())), tuple(q.key() for _, q in sorted(self.workqs.items())), tuple(w.key() for _, w in sorted(self.works.items())), + tuple(t.key() for _, t in sorted(self.timers.items())), tuple((p, tuple(v)) for p, v in sorted(self.ready.items()) if v), self.current, self.now, self.irq_nesting, self.sched_lock_depth, self.reschedule_pending, tuple(self.timeouts), self.timer_pending, self.dequeued, @@ -660,6 +716,7 @@ def clone(self) -> "Kernel": k.msgqs = {i: copy.copy(q) for i, q in self.msgqs.items()} k.workqs = {i: copy.copy(q) for i, q in self.workqs.items()} k.works = {i: copy.copy(w) for i, w in self.works.items()} + k.timers = {i: copy.copy(t) for i, t in self.timers.items()} k.ready = {p: list(v) for p, v in self.ready.items()} k.timeouts = list(self.timeouts) k.trace = [] @@ -1380,6 +1437,7 @@ def _op_work_pop(self, t: Thread, op: WorkPop): w = self.works[entry[1]] w.state = WORK_RUNNING wq.running = w.wid + self._event("work_run", w.wid, 0) t.wq_item, t.op_index, t.pc = w.wid, 0, 0 # the handler's body follows return if wq.stopping: @@ -1391,6 +1449,7 @@ def _op_work_done(self, t: Thread, op: WorkDone): wq, w = self.workqs[op.q], self.works[t.wq_item] if t.owned: raise ModelError(f"work item {w.wid} returned owning mutexes {t.owned} (SPEC-006 §6)") + self._event("work_run", w.wid, 1) wq.running = None w.runs += 1 if w.resubmitted: @@ -1746,12 +1805,20 @@ def step_thread(self) -> bool: res = self._op_unbind(op) elif isinstance(op, Submit): res = self._op_submit(op) + elif isinstance(op, TimerStart): + res = self._op_timer_start(op) else: res = self._op_thread_destroy(op) self._record(t, res) self._advance(t) if isinstance(op, Submit): self.reschedule_if_needed() + elif isinstance(op, TimerStop): + self._record(t, Result.SATISFIED, self._op_timer_stop(op)) + self._advance(t) + elif isinstance(op, TimerStopSync): + if self._op_timer_stop_sync(t, op): + self.reschedule_if_needed() elif isinstance(op, WorkCancel): self._record(t, Result.SATISFIED, self._op_work_cancel(op)) self._advance(t) @@ -1798,6 +1865,13 @@ def fire_isr(self, index: int): self.isr_fired = self.isr_fired | {index} self.irq_nesting += 1 for op in self.isr_bodies[index]: + self._isr_op(op) + self.irq_nesting -= 1 + self._p1() + + def _isr_op(self, op): + """One operation of an interrupt body or of an EMB_TIMER_ISR_CONTEXT callback.""" + if True: if not isinstance(op, ISR_SAFE_OPS): raise ModelError(f"{op!r} is not ISR-safe (SPEC-001 §5.2)") if isinstance(op, ISR_NO_WAIT_ONLY) and op.timeout != NO_WAIT: @@ -1824,23 +1898,121 @@ def fire_isr(self, index: int): self._op_submit(op) elif isinstance(op, WorkCancel): self._op_work_cancel(op) - self.irq_nesting -= 1 - self._p1() + elif isinstance(op, TimerStart): + self._op_timer_start(op) + elif isinstance(op, TimerStop): + self._op_timer_stop(op) def tick(self): self.now += 1 self._expire_due() def _expire_due(self): - """The timer interrupt: every deadline at or before now, in deadline order.""" + """The timer interrupt: every deadline at or before now, in deadline order. Timers come + from their own list (ADR-047): the earlier head first, a thread timeout first on equal + deadlines, and a timer armed by this pass waits for the next one.""" self.timer_pending = False self.irq_nesting += 1 - while self.timeouts and self.timeouts[0][0] <= self.now: - _, _, tid, gen = self.timeouts.pop(0) - self.wake_timeout(tid, gen) + timers_done = False + while True: + th = self.timeouts[0] if self.timeouts and self.timeouts[0][0] <= self.now else None + tm = None + if not timers_done: + armed = sorted((t.deadline, t.seq, t.tmid) for t in self.timers.values() if t.deadline is not None) + if armed and armed[0][0] <= self.now and (th is None or armed[0][0] < th[0]): + tm = self.timers[armed[0][2]] + if tm is not None: + if tm.armed_in_pass: + timers_done = True + continue + self._timer_expired(tm) + continue + if th is None: + break + self.timeouts.pop(0) + self.wake_timeout(th[2], th[3]) + for t in self.timers.values(): + t.armed_in_pass = False self.irq_nesting -= 1 self._p1() + # ------------------------------------------------------------------ software timers (SPEC-003 §7, M2-09) + def _timer_arm(self, tm: Timer, deadline: int): + tm.deadline, tm.seq = deadline, self._next_seq() + tm.armed_in_pass = self.irq_nesting > 0 and self.in_expiry + if self.tickless and deadline <= self.now: + self.timer_pending = True # an already-due compare + + def _timer_expired(self, tm: Timer): + tm.expiries += 1 + if tm.period: + nxt = tm.deadline + tm.period # from the previous deadline: no drift + if nxt <= self.now: # whole periods already past, closed form + skip = (self.now - nxt) // tm.period + 1 + tm.overruns += skip + nxt += skip * tm.period + self.in_expiry = True + self._timer_arm(tm, nxt) # before the callback + self.in_expiry = False + else: + tm.deadline = None + self._event("timer_expire", tm.tmid) + if tm.isr: + tm.in_callback, self.in_expiry = True, True + for op in self.timer_bodies[tm.tmid]: + self._isr_op(op) + tm.in_callback, self.in_expiry = False, False + return + w = self.works[TIMER_WORK_BASE + tm.tmid] + if w.state == WORK_PENDING or (w.state == WORK_RUNNING and w.resubmitted): + tm.overruns += 1 # coalesced + else: + self._op_submit(Submit(tm.q, w.wid)) + + def _op_timer_start(self, op: TimerStart) -> Result: + tm = self.timers[op.timer] + tm.deadline = None + tm.period = op.period + if op.period: + tm.origin = self.now + op.first + self._timer_arm(tm, self.now + op.first) + self._event("timer_start", tm.tmid) + return Result.SATISFIED + + def _op_timer_stop(self, op) -> int: + tm = self.timers[op.timer] + st = WORK_IDLE + if tm.deadline is not None: + tm.deadline = None + st = WORK_PENDING + if not tm.isr: + was = self._op_work_cancel(WorkCancel(TIMER_WORK_BASE + tm.tmid)) + if was != WORK_IDLE: + st = was + if tm.in_callback: + st = WORK_RUNNING + self._event("timer_stop", tm.tmid, st) + return st + + def _op_timer_stop_sync(self, t: Thread, op: TimerStopSync) -> bool: + tm = self.timers[op.timer] + if t.stop_phase == 0: + w = self.works.get(TIMER_WORK_BASE + tm.tmid) + if w is not None and w.state == WORK_RUNNING and self.wq_of_tid.get(t.tid) == tm.q: + self._finish(t, Result.EPERM) # its own callback: misuse + return True + self._op_timer_stop(op) # the stop section + t.stop_phase = 1 + return False + if tm.isr: + t.stop_phase = 0 + self._finish(t, Result.SATISFIED) # nothing runs under a thread + return True + done = self._op_work_cancel_sync(t, WorkCancelSync(TIMER_WORK_BASE + tm.tmid, op.timeout)) + if done: + t.stop_phase = 0 + return done + def _p1(self): if self.irq_nesting == 0 and self.reschedule_pending and self.sched_lock_depth == 0: self.dispatch() @@ -1875,6 +2047,11 @@ def check_invariants(self): for s in self.sems.values(): # ADR-046: no binding outlives its target if s.binding is not None and self.threads[s.binding[0]].destroyed: raise ModelError(f"semaphore {s.sid} is bound to destroyed thread {s.binding[0]}") + for tm in self.timers.values(): # SPEC-003 §7.4, KRN-TIM-015 + if tm.period and tm.deadline is not None and (tm.deadline - tm.origin) % tm.period != 0: + raise ModelError(f"timer {tm.tmid}: deadline {tm.deadline} drifted off {tm.origin} + k * {tm.period}") + if tm.deadline is not None and tm.armed_in_pass: + raise ModelError(f"timer {tm.tmid}: the pass marker survived its expiry pass") for w in self.works.values(): # SPEC-006 §4.3 places = [(wq.q, e) for wq in self.workqs.values() for e in wq.fifo if e == ("w", w.wid)] if (w.state == WORK_PENDING) != (len(places) == 1) or len(places) > 1: @@ -1980,7 +2157,13 @@ def enabled(self) -> list: for i in range(len(self.isr_bodies)): if i not in self.isr_fired: acts.append(("isr", i)) - if self.timeouts: + # A periodic timer keeps a tick enabled forever. The runner advances time only when + # idle or by one of its few scheduled ticks, so past the scenario's horizon the model + # ticks only while idle. + periodic = any(t.deadline is not None and t.period for t in self.timers.values()) + past = periodic and self.horizon is not None and self.now >= self.horizon + if (self.timeouts or any(t.deadline is not None for t in self.timers.values())) and \ + not (past and self.current is not None): acts.append(("tick",)) return acts @@ -2008,4 +2191,6 @@ def outcome(self) -> tuple: ("notify_bits", tuple((tid, t.notify_bits) for tid, t in sorted(self.threads.items()))), ("events", tuple((eid, e.bits) for eid, e in sorted(self.events.items()))), ("msgqs", tuple((qid, q.ring) for qid, q in sorted(self.msgqs.items()))), - ("works", tuple((wid, w.state, w.runs) for wid, w in sorted(self.works.items())))) + ("works", tuple((wid, w.state, w.runs) for wid, w in sorted(self.works.items()))), + ("timers", tuple((tm, t.expiries, t.overruns, t.deadline is not None) + for tm, t in sorted(self.timers.items())))) diff --git a/tools/model/evidence.py b/tools/model/evidence.py index a860840..2feb7ca 100644 --- a/tools/model/evidence.py +++ b/tools/model/evidence.py @@ -28,7 +28,8 @@ bridge.EventSet, bridge.EventClear, bridge.EventWait, bridge.EventDestroy, bridge.CondWait, bridge.CondSignal, bridge.CondBroadcast, bridge.CondDestroy, bridge.MsgSend, bridge.MsgSendFront, bridge.MsgRecv, bridge.MsgPeek, bridge.MsgDestroy, - bridge.Submit, bridge.WorkCancel, bridge.WorkCancelSync, bridge.WorkFlush, bridge.WorkqDestroy)} + bridge.Submit, bridge.WorkCancel, bridge.WorkCancelSync, bridge.WorkFlush, bridge.WorkqDestroy, + bridge.TimerStart, bridge.TimerStop, bridge.TimerStopSync)} def scenario_data(sc): @@ -48,6 +49,11 @@ def program(ops): if sc.workqs: # and work queues with their items' handlers data["workqs"] = list(sc.workqs.items()) data["works"] = [(w, dict(body=program(cfg.get("body", ())))) for w, cfg in sc.works.items()] + if sc.timers: # and software timers with their callbacks + data["timers"] = [(t, dict({k: v for k, v in cfg.items() if k != "body"}, + body=program(cfg.get("body", ())))) for t, cfg in sc.timers.items()] + if sc.horizon is not None: + data["horizon"] = sc.horizon return data @@ -93,6 +99,9 @@ def objects(items, enum_field=None, enum_type=None): msgqs=objects(raw.get("msgqs", []), "policy", bridge.Policy), workqs=objects(raw.get("workqs", [])), works={w: dict(body=program(cfg["body"])) for w, cfg in objects(raw.get("works", [])).items()}, + timers={t: dict(cfg, body=program(cfg["body"])) + for t, cfg in objects(raw.get("timers", [])).items()}, + horizon=raw.get("horizon"), isrs=tuple(program(ops) for ops in raw["isrs"]), inactive=tuple(raw["inactive"]), owner_death=raw["owner_death"], tickless=raw["tickless"]) diff --git a/tools/model/tests/scenarios.py b/tools/model/tests/scenarios.py index 8e9bf64..fb3327e 100644 --- a/tools/model/tests/scenarios.py +++ b/tools/model/tests/scenarios.py @@ -678,3 +678,64 @@ def work_flush_waits_for_running_handler(): WORKQ = [work_fifo_and_idempotent_submit, work_resubmit_while_running, work_cancel_states, work_flush_and_timeout, work_isr_submit, workq_destroy_joins_its_thread, work_flush_waits_for_running_handler] ALL = ALL + WORKQ + + +# --------------------------------------------------------------------------------------- +# M2-09: software timers (SPEC-003 §7): ISR_CONTEXT callbacks run their operations in the +# expiry pass; work queue callbacks run on queue 0's thread 9 +# --------------------------------------------------------------------------------------- +from embmodel import TimerStart, TimerStop, TimerStopSync # noqa: E402 + + +def timer_one_shot_isr_context(): + """A one-shot ISR_CONTEXT timer gives the semaphore its thread waits on.""" + return Scenario(name="timer_one_shot_isr_context", + threads={1: (1, (TimerStart(0, 2), Take(0)))}, + sems={0: dict(count=0)}, + timers={0: dict(isr=True, body=(Give(0),))}) + + +def timer_periodic_isr_stopped_by_thread(): + """A periodic timer expires on its grid until the thread stops it; a late stop sees one more.""" + return Scenario(name="timer_periodic_isr_stopped_by_thread", + threads={1: (1, (TimerStart(0, 1, 2), Take(0), Take(0), TimerStop(0)))}, + sems={0: dict(count=0)}, + timers={0: dict(isr=True, body=(Give(0),))}, horizon=10) + + +def timer_equal_deadlines_thread_first(): + """A thread timeout and a timer due at the same tick: the thread's expires first.""" + return Scenario(name="timer_equal_deadlines_thread_first", + threads={1: (1, (TimerStart(0, 2), Sleep(2))), 2: (3, (Take(0),))}, + sems={0: dict(count=0)}, + timers={0: dict(isr=True, body=(Give(0),))}) + + +def timer_work_queue_callback(): + """A timer's callback runs on its work queue's thread.""" + return Scenario(name="timer_work_queue_callback", + threads={1: (1, (TimerStart(0, 2), Take(0)))}, + sems={0: dict(count=0)}, + workqs={0: dict(tid=9, prio=2)}, + timers={0: dict(q=0, body=(Give(0),))}) + + +def timer_stop_running_callback_and_stop_sync(): + """stop reports a running callback; stop_sync waits until it returns.""" + return Scenario(name="timer_stop_running_callback_and_stop_sync", + threads={1: (1, (TimerStart(0, 1), Sleep(2), TimerStop(0), TimerStopSync(0)))}, + workqs={0: dict(tid=9, prio=2)}, + timers={0: dict(q=0, body=(Sleep(2),))}) + + +def timer_periodic_overrun_on_long_callback(): + """Expiries that find the callback's rerun already due are overruns, not extra runs.""" + return Scenario(name="timer_periodic_overrun_on_long_callback", + threads={1: (1, (TimerStart(0, 1, 1), Sleep(4), TimerStopSync(0)))}, + workqs={0: dict(tid=9, prio=2)}, + timers={0: dict(q=0, body=(Sleep(2),))}, horizon=10) + + +TIMER = [timer_one_shot_isr_context, timer_periodic_isr_stopped_by_thread, timer_equal_deadlines_thread_first, + timer_work_queue_callback, timer_stop_running_callback_and_stop_sync, timer_periodic_overrun_on_long_callback] +ALL = ALL + TIMER diff --git a/tools/model/tests/test_explore.py b/tools/model/tests/test_explore.py index c65bc6e..630592f 100644 --- a/tools/model/tests/test_explore.py +++ b/tools/model/tests/test_explore.py @@ -1,7 +1,7 @@ """Exhaustive exploration of every scenario (KRN-WAIT-007, KRN-WAIT-022) and outcome checks.""" import unittest -from embmodel import Result, explore +from embmodel import Result, build, explore from tests import scenarios as S @@ -506,3 +506,67 @@ def test_flush_waits_for_a_running_handler(self): self.assertEqual([(r[1], r[2]) for r in d[1]], [(Result.SATISFIED, 0), (Result.SATISFIED, 0), (Result.SATISFIED, 0)]) # idle after self.assertEqual(w[0], (0, 1)) + + +class SoftwareTimers(unittest.TestCase): + """M2-09 (SPEC-003 §7): one-shot and periodic expiry on a drift-free grid, ISR and work queue + callbacks, thread timeouts first on equal deadlines, stop states, stop_sync and overruns.""" + + @staticmethod + def outcomes(make): + return [(dict(dict(o)["threads"]), {t: (e, v, a) for t, e, v, a in dict(o)["timers"]}, dict(dict(o)["sems"])) + for o in explore(make()).outcomes] + + def test_every_timer_scenario_holds_invariants(self): + for make in S.TIMER: + sc = make() + with self.subTest(scenario=sc.name): + self.assertGreater(explore(sc).terminals, 0) + + def test_one_shot_and_work_queue_callbacks(self): + for make in (S.timer_one_shot_isr_context, S.timer_work_queue_callback): + for d, tm, _ in self.outcomes(make): + self.assertEqual(d[1][1][1], Result.SATISFIED) + self.assertEqual(tm[0], (1, 0, False)) + + def test_periodic_until_stopped(self): + seen = set() + for d, tm, sems in self.outcomes(S.timer_periodic_isr_stopped_by_thread): + exp = tm[0][0] + seen.add(exp) + self.assertEqual(sems[0], exp - 2) # every expiry gave once + self.assertFalse(tm[0][2]) + self.assertIn(2, seen) + + def test_equal_deadline_wakes_the_sleeper_first(self): + k = build(S.timer_equal_deadlines_thread_first()) + while k.enabled() and ("run",) in k.enabled(): + k.apply(("run",)) + k.apply(("tick",)) + k.trace = [] + k.apply(("tick",)) + wakes = [ev[1] for ev in k.trace if ev[0] == "wake"] + self.assertEqual(wakes, [1, 2]) # the sleeper, then the giver's taker + + def test_stop_sync_waits_for_the_running_callback(self): + seen = set() + for d, tm, _ in self.outcomes(S.timer_stop_running_callback_and_stop_sync): + res = {r[0]: (r[1], r[2]) for r in d[1]} + seen.add(res[2][1]) # RUNNING; PENDING or IDLE when ticks race + self.assertEqual(res[3][0], Result.SATISFIED) + self.assertIn(2, seen) + + def test_long_callbacks_count_overruns(self): + for d, tm, _ in self.outcomes(S.timer_periodic_overrun_on_long_callback): + self.assertEqual(d[1][-1][1], Result.SATISFIED) + self.assertFalse(tm[0][2]) + + def test_closed_form_catch_up(self): + from embmodel.kernel import Timer + k = build(S.timer_one_shot_isr_context()) + tm = Timer(tmid=7, isr=True, period=2, origin=2) + k.timers[7], k.timer_bodies[7] = tm, () + k._timer_arm(tm, 2) + k.now = 9 # 4, 6 and 8 were missed + k._expire_due() + self.assertEqual((tm.expiries, tm.overruns, tm.deadline), (1, 3, 10)) diff --git a/tools/toolchain/native_abi.c b/tools/toolchain/native_abi.c index 2582d34..be3d4d8 100644 --- a/tools/toolchain/native_abi.c +++ b/tools/toolchain/native_abi.c @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -140,6 +141,12 @@ void emb_native_abi_probe(void) FIELD(cond_obj, embk_cond_t, obj); FIELD(cond_waiters, embk_cond_t, waiters); #endif +#if CONFIG_EMB_TIMER + TYPE(timer, embk_timer_t); + FIELD(timer_node, embk_timer_t, node); + FIELD(timer_period, embk_timer_t, period); + FIELD(timer_expiries, embk_timer_t, expiries); +#endif #if CONFIG_EMB_WORKQ TYPE(work, embk_work_t); FIELD(work_node, embk_work_t, node); From 34a7c7c5606310bc68353a8f9ad2845d94039634 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Fri, 30 Oct 2026 22:36:08 +0100 Subject: [PATCH 21/30] feat(work): delayed work over internal timers (M2-09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delayed work (SPEC-006 §4.3, KRN-WQ-003, KRN-WQ-007) when both work queues and software timers are configured, in include/emb/work.h and kernel/work/dwork.c. - emb_dwork_t is a public emb_work_t, the handler's argument, followed by opaque storage for an internal ISR_CONTEXT timer whose only action is the O(1) submit (A61). timer.c exposes the internal-timer init, arm and stop it needs. - The queue is named at schedule time and holds a timer reference while the delay runs, so it cannot be destroyed. The target doubles as the armed marker: a cancel between expiry and submit leaves nothing to submit. - schedule and schedule_at return EEXIST while the delay runs or the item is queued, and EBUSY while the handler runs on another queue; reschedule replaces a running delay. cancel reports PENDING for a stopped delay or a removed item; cancel_sync also waits for a running handler. API IDs 137-142, trace ID 44. The reference model gives a delayed item an internal timer that submits it and checks that the delay's target and its timer agree; it now also refuses to destroy a queue a timer names, as the kernel does. The DWORK group covers a delay that runs once, idempotent schedule, reschedule and cancel, and ISR scheduling. The runner carries K sections and dsched, dresched and dcancel. test_workq.c gains four delayed-work cases. Signed-off-by: teo1747 --- CHANGELOG.md | 2 + docs/STATUS.md | 4 +- docs/engineering/resource-bounds.json | 16 +++ docs/engineering/resource-bounds.md | 4 + docs/requirements/KRN-WQ.md | 8 +- docs/specs/AMENDMENTS-M2.md | 1 + docs/user/feature-status.md | 2 +- docs/user/tasks.md | 2 + include/emb/api_ids.h | 6 + include/emb/trace.h | 3 +- include/emb/work.h | 126 ++++++++++++++++++++ kernel/CMakeLists.txt | 3 + kernel/include/embk/timer.h | 18 +++ kernel/object/storage_probe.c | 3 + kernel/time/timer.c | 37 ++++-- kernel/work/dwork.c | 163 ++++++++++++++++++++++++++ tests/conformance/test_workq.c | 103 ++++++++++++++++ tests/differential/diff_runner.c | 95 +++++++++++++-- tools/fault/api-ids.json | 36 ++++++ tools/fault/api-registry.json | 36 ++++++ tools/model/README.md | 2 +- tools/model/bridge.py | 13 +- tools/model/embmodel/__init__.py | 2 +- tools/model/embmodel/kernel.py | 69 ++++++++++- tools/model/evidence.py | 7 +- tools/model/tests/scenarios.py | 40 +++++++ tools/model/tests/test_explore.py | 40 +++++++ 27 files changed, 804 insertions(+), 37 deletions(-) create mode 100644 kernel/work/dwork.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a630b..b65cee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Added +- Delayed work (`emb_dwork_t`, M2-09, KRN-WQ-003/007): an item that an internal interrupt-context timer submits to a work queue after a delay or at an instant, with idempotent schedule, reschedule, cancel reporting a stopped delay or removed item as pending, and `cancel_sync`. The queue cannot be destroyed while a delay names it. The reference model, the differential bridge and the work queue conformance suite cover it. + - Software timers (`CONFIG_EMB_TIMER`, `include/emb/timer.h`, M2-09): one-shot and periodic timers whose callbacks run on a named work queue or, with `EMB_TIMER_ISR_CONTEXT`, in the expiry interrupt. Periodic timers re-arm from the previous deadline, skip missed periods in closed form and count them with coalesced callbacks as overruns; `stop` reports the callback's state and `stop_sync` waits for it. Timers have their own deadline list, merged with thread timeouts at expiry (ADR-047). The reference model, the differential bridge and a conformance suite cover them; they require the list scheduler, so the tiny profile has none in this milestone. - The RISC-V reset code is in its own `.text.embn_rv_reset` section, so an application function named `reset` built with `-ffunction-sections` can no longer take the reset vector's place. diff --git a/docs/STATUS.md b/docs/STATUS.md index 58f06d0..8260224 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -13,7 +13,7 @@ This page is the authoritative inventory of implementation and available evidenc | Synchronization | Semaphores, mutexes with inheritance, ceiling, recursion and owner-death policy; event flags with ANY/ALL/CLEAR waits and binding (M2-05); condition variables with the mandatory relock (M2-06) | Conformance and native differential bridge; inheritance and deadlock detection have a configured depth limit | | Communication | Copy-by-value message queues: direct hand-off to a waiting receiver, slot hand-off from a waiting sender, send to front, peek, `EMB_NO_WAIT` calls from ISRs and a receive-side binding (M2-07) | Conformance and native differential bridge; no pipes, buffer pools or zero-copy ownership queues; the masked copy time is bounded by configuration, not measured on hardware | | Software timers | One-shot and drift-free periodic timers in their own deadline list (ADR-047), with callbacks on a work queue or in interrupt context, closed-form catch-up and overrun counts, stop states and `stop_sync` (M2-09) | Conformance and native differential bridge; list scheduler only (not tiny); arming overhead and jitter not measured on hardware | -| Deferred work | Work queues on a kernel thread with FIFO execution, idempotent and ISR-safe submit, resubmission while running, cancel and synchronous cancel, flush, destroy joining the thread, and a system queue linked only when referenced (M2-08, ADR-048) | Conformance and native differential bridge; list scheduler only (not tiny); no delayed work, timers or statistics | +| Deferred work | Work queues on a kernel thread with FIFO execution, idempotent and ISR-safe submit, resubmission while running, cancel and synchronous cancel, flush, destroy joining the thread, a system queue linked only when referenced (M2-08, ADR-048), and delayed work items over internal timers (M2-09) | Conformance and native differential bridge; list scheduler only (not tiny); no statistics | | Notifications | Per-thread bits and one semaphore-to-thread binding, with unbind; a bound thread cannot be destroyed (ADR-046) | Binding takes a one-bit mask; other objects' bindings arrive with those objects; restart generations need the isolated profile | | Objects | Caller-owned generated storage and POINTER handles; per-type active-object lists for semaphores and mutexes (`CONFIG_EMB_OBJ_LIST`) used by init | No INDEXED or TABLE handles, capability rights, partition restart, system freeze, `emb_obj_foreach()` or debug-descriptor export | | Native port | POSIX host-thread gate, deterministic IRQ injection, virtual time | Base/tiny development workflow; no current wall-clock, asynchronous signal stress, simulated isolated profile or Win32 backend | @@ -26,7 +26,7 @@ This page is the authoritative inventory of implementation and available evidenc ## Specified but unimplemented -Barriers, delayed work, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. +Barriers, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. An API or path named in a design or a requirement's verification field may be a planned deliverable. Check this inventory and the filesystem before treating it as available. diff --git a/docs/engineering/resource-bounds.json b/docs/engineering/resource-bounds.json index d8577b7..54ce2be 100644 --- a/docs/engineering/resource-bounds.json +++ b/docs/engineering/resource-bounds.json @@ -399,6 +399,22 @@ "emb_timer_stop_sync": [ "waiters", "timeouts" + ], + "emb_dwork_schedule": [ + "armed_timers", + "timeouts" + ], + "emb_dwork_schedule_at": [ + "armed_timers", + "timeouts" + ], + "emb_dwork_reschedule": [ + "armed_timers", + "timeouts" + ], + "emb_dwork_cancel_sync": [ + "waiters", + "timeouts" ] } } diff --git a/docs/engineering/resource-bounds.md b/docs/engineering/resource-bounds.md index c701797..ff7a4c9 100644 --- a/docs/engineering/resource-bounds.md +++ b/docs/engineering/resource-bounds.md @@ -33,6 +33,10 @@ Every cost input named by a public function's `@time` annotation, with how it is | `emb_cond_signal()` | O(1) selection, plus profile-specific timeout removal and scheduling | timeouts, reschedule | | `emb_cond_wait()` | O(waiters + timeouts) enqueue, a full mutex release, then the cost of emb_mutex_lock() | waiters, timeouts, owned, pi_depth, reschedule | | `emb_cond_wait_until()` | as emb_cond_wait() | waiters, timeouts, owned, pi_depth, reschedule | +| `emb_dwork_cancel_sync()` | O(1); O(waiters + timeouts) finite wait enqueue | waiters, timeouts | +| `emb_dwork_reschedule()` | O(armed timers) insertion | armed_timers, timeouts | +| `emb_dwork_schedule()` | O(armed timers) insertion | armed_timers, timeouts | +| `emb_dwork_schedule_at()` | O(armed timers) insertion | armed_timers, timeouts | | `emb_event_destroy()` | O(waiters) with EMB_OBJ_ABORT_WAITERS | batch_wake, reschedule | | `emb_event_init()` | O(1); O(active event flags) list lookup with CONFIG_EMB_OBJ_LIST | active_objects | | `emb_event_set()` | O(waiters) in one section, one reschedule | waiters, batch_wake, timeouts, reschedule | diff --git a/docs/requirements/KRN-WQ.md b/docs/requirements/KRN-WQ.md index 8241c94..f3bffd5 100644 --- a/docs/requirements/KRN-WQ.md +++ b/docs/requirements/KRN-WQ.md @@ -25,8 +25,8 @@ KRN-WQ-001 to 004 originate in `docs/architecture/03-kernel-architecture.md` §6 ### KRN-WQ-003 Delayed work over software timers **Statement.** Delayed work shall be implemented over software timers, not a separate timing mechanism. **Rationale.** One timeout structure (ADR-010); one set of accuracy rules. -**Status.** Accepted 2026-10-07 -**Verification.** Analysis: `emb_dwork_t` embeds `emb_timer_storage_t`; no other timing code in `kernel/work/`. +**Status.** Accepted 2026-10-07; implemented 2026-10-30 (M2-09). +**Verification.** Analysis: `emb_dwork_t` holds a public work item and an internal ISR_CONTEXT software timer (`kernel/work/dwork.c`); no other timing code in `kernel/work/`. **Trace.** SPEC-006 §4.1, §4.3; SPEC-003 §7 ### KRN-WQ-004 Cancellation reports the state @@ -53,8 +53,8 @@ KRN-WQ-001 to 004 originate in `docs/architecture/03-kernel-architecture.md` §6 ### KRN-WQ-007 Delayed work scheduling **Statement.** `dwork_schedule` on a pending item shall return `EMB_EEXIST` and change nothing; `reschedule` shall replace a pending delay; cancel shall stop an armed timer or remove a queued item and report `PENDING` for both; the timer's expiry shall submit the item in O(1) from the expiry path. **Rationale.** One queue hop per delayed item; deterministic re-arm semantics. -**Status.** Accepted 2026-10-07 -**Verification.** Test: `wq/dwork_schedule_pending`, `wq/dwork_reschedule`, `wq/dwork_cancel_states`. +**Status.** Accepted 2026-10-07; implemented 2026-10-30 (M2-09). +**Verification.** Test: `dwork_runs_after_its_delay_and_schedule_is_idempotent`, `dwork_reschedule_replaces_the_delay`, `dwork_cancel_reports_pending_for_a_delay_and_a_queued_item`, `dwork_cancel_sync_waits_for_a_running_handler`; model scenarios of the DWORK group; differential agreement on the native presets. **Trace.** SPEC-006 §4.3, §12.6, §12.7 ### KRN-WQ-008 System work queue diff --git a/docs/specs/AMENDMENTS-M2.md b/docs/specs/AMENDMENTS-M2.md index c118398..b3bf6ff 100644 --- a/docs/specs/AMENDMENTS-M2.md +++ b/docs/specs/AMENDMENTS-M2.md @@ -36,3 +36,4 @@ Corrections found while preparing the M2 primitives, where an accepted specifica | A58 | SPEC-003 §7.2 | Start forms are O(armed timers); `stop` is O(1). `emb_timer_stop_sync()` waits only for a work-queue callback; an ISR-context callback cannot be running under a thread, so it returns at once. | `include/emb/timer.h` | | A59 | SPEC-003 §7.4, SPEC-015 | Trace records `EMB_TRACE_TIMER_EXPIRE` (with the periods skipped), `EMB_TRACE_TIMER_START` and `EMB_TRACE_TIMER_STOP` (with the state found); `stop_sync`'s outcome is the `EMB_TRACE_WORK_WAIT` record. There is no separate overrun event: coalesced callbacks show as an expiry without a submit. | `include/emb/trace.h` | | A60 | SPEC-003 §14, §15 | The reference model covers timers (ISR-context callbacks running model operations, work-queue callbacks through the embedded work item); the differential bridge follows timer starts, stops, expiries and handler runs as events. An exploration horizon stops time from advancing while a thread could run once a periodic timer has run long enough, as the runner does. The tiny profile has no timers in M2 (maintainer decision, 2026-10-27). | `tools/model/embmodel/kernel.py` | +| A61 | SPEC-006 §4.1, §4.2, §4.3, KRN-WQ-007 | `emb_dwork_t` is a public `emb_work_t` (the handler's argument) followed by opaque storage for an internal ISR_CONTEXT timer. The queue is named at schedule time and holds a timer reference while the delay runs, so it cannot be destroyed. `schedule` returns `EMB_EEXIST` while the delay runs or the item is queued, `EMB_EBUSY` while its handler runs on another queue; `reschedule` replaces a running delay and returns `EMB_EEXIST` for a queued item. Cancel reports `EMB_WORK_PENDING` for a stopped delay or a removed item. The trace record is `EMB_TRACE_DWORK_SCHEDULE`; the delay's own start and expiry are the timer records. | `kernel/work/dwork.c`, `include/emb/work.h` | diff --git a/docs/user/feature-status.md b/docs/user/feature-status.md index 07a51eb..db3cc34 100644 --- a/docs/user/feature-status.md +++ b/docs/user/feature-status.md @@ -7,7 +7,7 @@ This guide describes the current 0.2 development implementation. The authoritati | Start and coordinate threads | Static storage, priorities, start, sleep, join/detach, cooperative cancellation | Isolated partitions, budgets, restart, SMP | | Protect or signal work | Mutexes, semaphores, per-thread notifications, event flags, condition variables | Barriers | | Transfer payloads | Copy-by-value message queues; application-owned data with an explicit synchronization protocol | Pipes, buffer pools, zero-copy ownership queues | -| Run periodic work | A thread using absolute sleep deadlines; one-shot and drift-free periodic software timers; work queues and the system work queue for deferred work (not with the tiny profile) | Delayed work items, timers on the tiny profile | +| Run periodic work | A thread using absolute sleep deadlines; one-shot and drift-free periodic software timers; work queues, delayed work items and the system work queue for deferred work (not with the tiny profile) | Timers and work queues on the tiny profile | | Wait on several readiness sources | Semaphore, event flag and message queue bindings, with unbind, plus notification bits and rechecking; a bound thread cannot be destroyed | Pipe and pool bindings, restart-safe target lifetime | | Diagnose failures | State/priority queries, optional stack estimates, synchronous trace and fault hooks | Retained crash formats, CTF rings, full host tooling | | Develop without a board | Native Linux/POSIX virtual time and conformance suite | Physical-board timing, peripheral drivers and HIL evidence | diff --git a/docs/user/tasks.md b/docs/user/tasks.md index 6fcd2e3..6c45602 100644 --- a/docs/user/tasks.md +++ b/docs/user/tasks.md @@ -125,6 +125,8 @@ emb_work_init(&job.work, process); An item is never queued twice and never runs concurrently with itself; submitting it while its handler runs queues it once more for after the handler returns. `emb_work_cancel()` reports whether the item was pending (now removed), running (it completes) or idle. Before reusing an item's storage, call `emb_work_cancel_sync()`, which also waits for a running handler. `emb_workq_flush()` waits until everything queued before the call has run. Calling either from the queue's own handlers would wait forever and is misuse, as is returning from a handler with a mutex held. The queue's thread belongs to the kernel: thread calls on it are refused and it cannot be notified directly. `emb_workq_destroy()` needs an idle queue and returns after the thread has exited. Referencing `emb_workq_system()` links in a shared system queue at a high priority; keep its handlers short. +For work that should run later, an `emb_dwork_t` adds a delay: `emb_dwork_init(&dw, fn)`, then `emb_dwork_schedule(queue, &dw, EMB_MS(50))` submits it when the delay expires. Scheduling it again while the delay runs returns `EMB_EEXIST`; `emb_dwork_reschedule()` replaces the delay instead. `emb_dwork_cancel()` stops the delay or removes the queued item, and `emb_dwork_cancel_sync()` also waits for a running handler. The handler receives `&dw.work`. + ## Periodic work with a software timer A software timer (`CONFIG_EMB_TIMER`, like work queues not available with the tiny profile in this milestone) calls a function after a delay, once or periodically. By default the callback runs on a work queue you name, in thread context; with `EMB_TIMER_ISR_CONTEXT` it runs in the timer interrupt and must be short and ISR-safe: diff --git a/include/emb/api_ids.h b/include/emb/api_ids.h index 955e809..05f9ca3 100644 --- a/include/emb/api_ids.h +++ b/include/emb/api_ids.h @@ -140,5 +140,11 @@ #define EMB_API_ID_TIMER_IS_RUNNING 0x0086u #define EMB_API_ID_TIMER_REMAINING 0x0087u #define EMB_API_ID_TIMER_GET_COUNTS 0x0088u +#define EMB_API_ID_DWORK_INIT 0x0089u +#define EMB_API_ID_DWORK_SCHEDULE 0x008Au +#define EMB_API_ID_DWORK_SCHEDULE_AT 0x008Bu +#define EMB_API_ID_DWORK_RESCHEDULE 0x008Cu +#define EMB_API_ID_DWORK_CANCEL 0x008Du +#define EMB_API_ID_DWORK_CANCEL_SYNC 0x008Eu #endif /* EMB_API_IDS_H */ diff --git a/include/emb/trace.h b/include/emb/trace.h index 76ae580..7b50dc4 100644 --- a/include/emb/trace.h +++ b/include/emb/trace.h @@ -60,7 +60,8 @@ enum emb_trace_id { destroy, timer stop_sync) */ EMB_TRACE_TIMER_EXPIRE = 41, /* a0 = periods skipped, a1 = timer address, a2 = expiries */ EMB_TRACE_TIMER_START = 42, /* a0 = deadline low word, a1 = timer address, a2 = period */ - EMB_TRACE_TIMER_STOP = 43 /* a0 = index or 0xFF, a1 = timer address, a2 = state found */ + EMB_TRACE_TIMER_STOP = 43, /* a0 = index or 0xFF, a1 = timer address, a2 = state found */ + EMB_TRACE_DWORK_SCHEDULE = 44 /* a0 = index or 0xFF, a1 = item address, a2 = status */ }; typedef struct emb_trace_event { diff --git a/include/emb/work.h b/include/emb/work.h index ee347f3..f274487 100644 --- a/include/emb/work.h +++ b/include/emb/work.h @@ -253,6 +253,132 @@ emb_status_t emb_work_cancel_sync(emb_work_t *work, emb_timeout_t timeout); */ emb_work_state_t emb_work_state(const emb_work_t *work); +#if CONFIG_EMB_TIMER +/* Delayed work (SPEC-006 §4.3, KRN-WQ-003): a work item and the internal timer whose + * expiry submits it. The handler receives &dwork->work. */ +typedef struct emb_dwork { + emb_work_t work; /* EMB_CONTAINER_OF(work, emb_dwork_t, work) in the handler */ + emb_dwork_storage_t timer_; /* internal */ +} emb_dwork_t; + +/** + * emb_dwork_init() - Initialize a delayed work item that runs @fn. + * @dwork: Caller-owned item, not scheduled, queued or running; NULL is a no-op. + * @fn: Handler, run on the queue's thread with &dwork->work. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_WORKQ CONFIG_EMB_TIMER + * @req KRN-WQ-003 + * @since 0.3 + * @stable no + */ +void emb_dwork_init(emb_dwork_t *dwork, emb_work_fn_t fn); + +/** + * emb_dwork_schedule() - Submit the item to @workq after @delay. + * @workq: Initialized work queue; it cannot be destroyed while the delay runs. + * @dwork: Initialized delayed work item. + * @delay: Delay; it saturates to EMB_TIMEOUT_MAX_TICKS, and 0 submits at the next pass. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(armed timers) insertion + * @owns none + * @config CONFIG_EMB_WORKQ CONFIG_EMB_TIMER + * @req KRN-WQ-003 KRN-WQ-007 + * @since 0.3 + * @stable no + * + * The expiry submits the item in O(1) from the expiry path, as emb_work_submit() would. + * + * Return: EMB_OK; EMB_EEXIST when the delay already runs or the item is queued (nothing + * changes); EMB_EBUSY while its handler runs on another queue; EMB_ESTATE while + * @workq is being destroyed; EMB_ENOMEM when 255 timers name @workq; EMB_EINVAL for + * a bad handle or an item that was not initialized. + */ +emb_status_t emb_dwork_schedule(emb_workq_t workq, emb_dwork_t *dwork, emb_duration_t delay); + +/** + * emb_dwork_schedule_at() - Submit the item to @workq at an absolute instant. + * @workq: Initialized work queue. + * @dwork: Initialized delayed work item. + * @at: Kernel-clock instant; a past one submits at the next pass. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(armed timers) insertion + * @owns none + * @config CONFIG_EMB_WORKQ CONFIG_EMB_TIMER + * @req KRN-WQ-007 KRN-TIM-011 + * @since 0.3 + * @stable no + * + * Return: the statuses of emb_dwork_schedule(). + */ +emb_status_t emb_dwork_schedule_at(emb_workq_t workq, emb_dwork_t *dwork, emb_instant_t at); + +/** + * emb_dwork_reschedule() - Replace a running delay, or schedule. + * @workq: Initialized work queue. + * @dwork: Initialized delayed work item. + * @delay: The new delay, from now. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(armed timers) insertion + * @owns none + * @config CONFIG_EMB_WORKQ CONFIG_EMB_TIMER + * @req KRN-WQ-007 + * @since 0.3 + * @stable no + * + * Return: the statuses of emb_dwork_schedule(), except that a running delay is replaced; + * EMB_EEXIST when the item is already queued (it stays queued). + */ +emb_status_t emb_dwork_reschedule(emb_workq_t workq, emb_dwork_t *dwork, emb_duration_t delay); + +/** + * emb_dwork_cancel() - Stop the delay or remove the queued item; never waits. + * @dwork: Initialized delayed work item. + * @out_state: Receives EMB_WORK_PENDING for a stopped delay or a removed item, + * EMB_WORK_RUNNING when the handler runs (it completes), else EMB_WORK_IDLE; + * may be NULL. + * + * @ctx thread isr prekernel + * @blocks no + * @time O(1) + * @owns none + * @config CONFIG_EMB_WORKQ CONFIG_EMB_TIMER + * @req KRN-WQ-007 KRN-WQ-004 + * @since 0.3 + * @stable no + * + * Return: EMB_OK; EMB_EINVAL for an item that was not initialized. + */ +emb_status_t emb_dwork_cancel(emb_dwork_t *dwork, emb_work_state_t *out_state); + +/** + * emb_dwork_cancel_sync() - Cancel, then wait until a running handler has returned. + * @dwork: Initialized delayed work item. + * @timeout: EMB_NO_WAIT, EMB_WAIT_FOREVER or EMB_TIMEOUT(duration). + * + * @ctx thread + * @blocks timeout + * @time O(1); O(waiters + timeouts) finite wait enqueue + * @owns none + * @config CONFIG_EMB_WORKQ CONFIG_EMB_TIMER + * @req KRN-WQ-007 KRN-WQ-006 + * @since 0.3 + * @stable no + * + * Return: the statuses of emb_work_cancel_sync(). + */ +emb_status_t emb_dwork_cancel_sync(emb_dwork_t *dwork, emb_timeout_t timeout); +#endif + #ifdef __cplusplus } #endif diff --git a/kernel/CMakeLists.txt b/kernel/CMakeLists.txt index 2969706..e59da51 100644 --- a/kernel/CMakeLists.txt +++ b/kernel/CMakeLists.txt @@ -39,6 +39,9 @@ endif() if(CONFIG_EMB_TIMER) list(APPEND EMB_KERNEL_SOURCES time/timer.c) endif() +if(CONFIG_EMB_TIMER AND CONFIG_EMB_WORKQ) + list(APPEND EMB_KERNEL_SOURCES work/dwork.c) +endif() if(CONFIG_EMB_SYSTEM_WORKQ) list(APPEND EMB_KERNEL_SOURCES work/system_workq.c) # linked only when referenced (ADR-048) endif() diff --git a/kernel/include/embk/timer.h b/kernel/include/embk/timer.h index d5e510e..913a140 100644 --- a/kernel/include/embk/timer.h +++ b/kernel/include/embk/timer.h @@ -39,9 +39,27 @@ typedef struct embk_timer { extern embk_list_t embk_timer_all; /* every active timer; lock: scheduler domain */ #endif +#if CONFIG_EMB_WORKQ +/* The internal part of a delayed work item (KRN-WQ-003); its work item is public. */ +typedef struct embk_dwork { + embk_timer_t timer; /* ISR_CONTEXT: its expiry submits the item */ + embk_workq_t *target; /* the queue it submits to while scheduled, holding a timer reference */ +} embk_dwork_t; +#endif + /* An armed timer's deadline passed (SPEC-003 §7.4); called by the expiry pass with the * timeout lock held as *key, which an EMB_TIMER_ISR_CONTEXT callback runs without. */ void embk_timer_expired(embk_timer_t *t, emb_tick_t now, emb_irq_key_t *key); + +/* Kernel-internal timers (delayed work, KRN-WQ-003): an ISR_CONTEXT timer that no handle + * names and the active-timer list does not hold. */ +void embk_timer_init_isr(embk_timer_t *t, void (*fn)(struct emb_timer_t timer, void *arg), + void *arg); +/* (Re)arm at @deadline with @period (0: one-shot); lock held; the counts are kept. */ +void embk_timer_arm_locked(embk_timer_t *t, emb_tick_t deadline, emb_tick_t period, + emb_tick_t interval); +/* Disarm and drop a queued callback; lock held; returns an emb_timer_state_t. */ +uint8_t embk_timer_stop_locked(embk_timer_t *t); #endif #endif /* EMBK_TIMER_H */ diff --git a/kernel/object/storage_probe.c b/kernel/object/storage_probe.c index 03766bb..0a68a8b 100644 --- a/kernel/object/storage_probe.c +++ b/kernel/object/storage_probe.c @@ -48,4 +48,7 @@ void embk_storage_probe(void) #if CONFIG_EMB_TIMER PROBE(timer, embk_timer_t); #endif +#if CONFIG_EMB_TIMER && CONFIG_EMB_WORKQ + PROBE(dwork, embk_dwork_t); +#endif } diff --git a/kernel/time/timer.c b/kernel/time/timer.c index 95c41b5..e0ac6e4 100644 --- a/kernel/time/timer.c +++ b/kernel/time/timer.c @@ -100,6 +100,25 @@ void embk_timer_expired(embk_timer_t *t, emb_tick_t now, emb_irq_key_t *key) t->in_callback = 0u; } +void embk_timer_init_isr(embk_timer_t *t, void (*fn)(struct emb_timer_t timer, void *arg), + void *arg) +{ + embk_obj_init(&t->obj, EMBK_OBJ_TIMER, EMB_TIMER_ISR_CONTEXT, NULL); + embk_list_node_init(&t->node.node); + t->period = 0u; + t->interval = 0u; + t->expiries = 0u; + t->overruns = 0u; + t->fn = fn; + t->arg = arg; + t->pass = 0u; + t->in_callback = 0u; +#if CONFIG_EMB_WORKQ + t->queue = NULL; + embk_work_init(&t->work, timer_work); +#endif +} + void emb_timer_attr_default(emb_timer_attr_t *out_attr) { if (out_attr == NULL) { @@ -194,7 +213,7 @@ emb_status_t emb_timer_init(emb_timer_storage_t *storage, const emb_timer_attr_t } /* The state stop finds and leaves: disarmed, a queued callback dropped; lock held. */ -static emb_timer_state_t stop_locked(embk_timer_t *t) +uint8_t embk_timer_stop_locked(embk_timer_t *t) { emb_timer_state_t st = EMB_TIMER_IDLE; if (embk_list_node_is_linked(&t->node.node)) { @@ -249,7 +268,8 @@ emb_status_t emb_timer_destroy(emb_timer_t timer) } /* (Re)arm at @deadline; lock held. The counts are kept. */ -static void arm_locked(embk_timer_t *t, emb_tick_t deadline, emb_tick_t period, emb_tick_t interval) +void embk_timer_arm_locked(embk_timer_t *t, emb_tick_t deadline, emb_tick_t period, + emb_tick_t interval) { embk_timer_node_disarm(&t->node); t->period = period; @@ -268,7 +288,8 @@ static emb_status_t start_common(uint16_t api_id, emb_timer_t timer, emb_tick_t EMBK_API_REQUIRE(api_id, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); after.ticks = first; key = embk_lock_timeout(); - arm_locked(t, embk_deadline_from_timeout(after), period, (period != 0u) ? period : first); + embk_timer_arm_locked(t, embk_deadline_from_timeout(after), period, + (period != 0u) ? period : first); embk_unlock_timeout(key); return EMB_OK; } @@ -286,8 +307,8 @@ emb_status_t emb_timer_start_at(emb_timer_t timer, emb_instant_t at) EMBK_API_REQUIRE(EMB_API_ID_TIMER_START_AT, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); key = embk_lock_timeout(); now = embk_time_ticks_locked(); - arm_locked(t, at.ticks, 0u, - embk_tick_before(now, at.ticks) ? (emb_tick_t)(at.ticks - now) : 0u); + embk_timer_arm_locked(t, at.ticks, 0u, + embk_tick_before(now, at.ticks) ? (emb_tick_t)(at.ticks - now) : 0u); embk_unlock_timeout(key); return EMB_OK; } @@ -313,7 +334,7 @@ emb_status_t emb_timer_restart(emb_timer_t timer) return EMB_ESTATE; } after.ticks = t->interval; - arm_locked(t, embk_deadline_from_timeout(after), t->period, t->interval); + embk_timer_arm_locked(t, embk_deadline_from_timeout(after), t->period, t->interval); embk_unlock_timeout(key); return EMB_OK; } @@ -330,7 +351,7 @@ emb_status_t emb_timer_stop(emb_timer_t timer, emb_timer_state_t *out_state) emb_timer_state_t st; EMBK_API_REQUIRE(EMB_API_ID_TIMER_STOP, t != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); key = embk_lock_timeout(); - st = stop_locked(t); + st = embk_timer_stop_locked(t); EMBK_TRACE(EMB_TRACE_TIMER_STOP, caller_index(), t, st); embk_unlock_timeout(key); if (out_state != NULL) { @@ -359,7 +380,7 @@ emb_status_t emb_timer_stop_sync(emb_timer_t timer, emb_timeout_t timeout) EMBK_API_MISUSE(EMB_API_ID_TIMER_STOP_SYNC, EMB_FAULT_API_CONTEXT, EMB_EPERM, 1u); } #endif - st = stop_locked(t); + st = embk_timer_stop_locked(t); EMBK_TRACE(EMB_TRACE_TIMER_STOP, embk_thread_index(embk_cpu.current), t, st); embk_unlock_timeout(key); #if CONFIG_EMB_WORKQ diff --git a/kernel/work/dwork.c b/kernel/work/dwork.c new file mode 100644 index 0000000..30b24ad --- /dev/null +++ b/kernel/work/dwork.c @@ -0,0 +1,163 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Copyright 2026 Junior Deogracias */ +/* + * Delayed work (SPEC-006 §4.3, KRN-WQ-003, KRN-WQ-007): a public work item and an + * internal ISR_CONTEXT timer whose only action is the O(1) submit, so no second queue hop + * is paid. While the delay runs the item holds a timer reference on its target queue, + * which therefore cannot be destroyed (A55). The target is the armed marker: cancel clears + * it, so an expiry already past its timer but not yet submitting does nothing. + */ +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#if CONFIG_EMB_WORKQ && CONFIG_EMB_TIMER + +EMB_STATIC_ASSERT(sizeof(embk_dwork_t) <= EMB_DWORK_STORAGE_SIZE, "generated storage too small"); + +static EMB_INLINE embk_work_t *item_of(emb_dwork_t *dw) +{ + embk_work_t *w = (embk_work_t *)(void *)&dw->work; + return (w->tag == EMBK_WORK_TAG) ? w : NULL; +} + +static EMB_INLINE embk_dwork_t *dwork_of(emb_dwork_t *dw) +{ + return (embk_dwork_t *)(void *)&dw->timer_; +} + +static EMB_INLINE uintptr_t caller_index(void) +{ + return (embk_cpu.current != NULL) ? embk_thread_index(embk_cpu.current) : 0xFFu; +} + +/* The internal timer's callback: the expiry path, interrupt context, timeout lock released. */ +static void fire(emb_timer_t timer, void *arg) +{ + emb_dwork_t *dw = (emb_dwork_t *)arg; + embk_dwork_t *d = dwork_of(dw); + emb_irq_key_t key = embk_lock_sched(); + (void)timer; + if (d->target != NULL) { + (void)embk_work_submit_locked(d->target, item_of(dw)); /* O(1) (KRN-WQ-007) */ + d->target->timer_refs--; + d->target = NULL; + } + embk_unlock_sched(key); /* the interrupt's exit reschedules */ +} + +/* Stop the delay and release its queue reference; lock held. */ +static bool disarm_locked(embk_dwork_t *d) +{ + if (d->target == NULL) { + return false; + } + (void)embk_timer_stop_locked(&d->timer); + d->target->timer_refs--; + d->target = NULL; + return true; +} + +void emb_dwork_init(emb_dwork_t *dwork, emb_work_fn_t fn) +{ + embk_dwork_t *d; + if (dwork == NULL) { + return; + } + d = dwork_of(dwork); + embk_work_init((embk_work_t *)(void *)&dwork->work, fn); + embk_timer_init_isr(&d->timer, fire, dwork); + d->target = NULL; +} + +static emb_status_t schedule_common(uint16_t api_id, emb_workq_t workq, emb_dwork_t *dwork, + emb_tick_t deadline, bool replace) +{ + embk_workq_t *q = embk_workq_from_raw(workq.raw); + embk_work_t *w = (dwork != NULL) ? item_of(dwork) : NULL; + embk_dwork_t *d; + emb_irq_key_t key; + emb_status_t st = EMB_OK; + EMBK_API_REQUIRE(api_id, q != NULL, EMB_FAULT_API_HANDLE, EMB_EINVAL, 1u); + EMBK_API_REQUIRE(api_id, w != NULL && w->fn != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 2u); + d = dwork_of(dwork); + key = embk_lock_sched(); + if (q->stopping != 0u) { + st = EMB_ESTATE; /* being destroyed */ + } else if (w->state == EMB_WORK_PENDING || (d->target != NULL && !replace)) { + st = EMB_EEXIST; /* queued, or the delay runs: nothing changes */ + } else if (w->state == EMB_WORK_RUNNING && w->queue != q) { + st = EMB_EBUSY; /* an item belongs to one queue at a time */ + } else if (d->target != q && q->timer_refs == UINT8_MAX) { + st = EMB_ENOMEM; + } else { + (void)disarm_locked(d); /* reschedule: the old delay goes */ + d->target = q; + q->timer_refs++; + embk_timer_arm_locked(&d->timer, deadline, 0u, 0u); + } + EMBK_TRACE(EMB_TRACE_DWORK_SCHEDULE, caller_index(), dwork, st); + embk_unlock_sched(key); + return st; +} + +static emb_tick_t deadline_after(emb_duration_t delay) +{ + return embk_deadline_from_timeout(emb_timeout_from_duration(delay)); +} + +emb_status_t emb_dwork_schedule(emb_workq_t workq, emb_dwork_t *dwork, emb_duration_t delay) +{ + return schedule_common(EMB_API_ID_DWORK_SCHEDULE, workq, dwork, deadline_after(delay), false); +} + +emb_status_t emb_dwork_schedule_at(emb_workq_t workq, emb_dwork_t *dwork, emb_instant_t at) +{ + return schedule_common(EMB_API_ID_DWORK_SCHEDULE_AT, workq, dwork, at.ticks, false); +} + +emb_status_t emb_dwork_reschedule(emb_workq_t workq, emb_dwork_t *dwork, emb_duration_t delay) +{ + return schedule_common(EMB_API_ID_DWORK_RESCHEDULE, workq, dwork, deadline_after(delay), true); +} + +emb_status_t emb_dwork_cancel(emb_dwork_t *dwork, emb_work_state_t *out_state) +{ + embk_work_t *w = (dwork != NULL) ? item_of(dwork) : NULL; + emb_irq_key_t key; + emb_work_state_t was; + EMBK_API_REQUIRE(EMB_API_ID_DWORK_CANCEL, w != NULL, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + key = embk_lock_sched(); + was = disarm_locked(dwork_of(dwork)) ? EMB_WORK_PENDING : embk_work_cancel_locked(w); + embk_unlock_sched(key); + if (out_state != NULL) { + *out_state = was; + } + return EMB_OK; +} + +emb_status_t emb_dwork_cancel_sync(emb_dwork_t *dwork, emb_timeout_t timeout) +{ + embk_work_t *w = (dwork != NULL) ? item_of(dwork) : NULL; + emb_tick_t deadline = embk_deadline_from_timeout(timeout); /* fixed at the call */ + emb_irq_key_t key; + EMBK_API_REQUIRE_THREAD(EMB_API_ID_DWORK_CANCEL_SYNC); + if (w == NULL) { + EMBK_TRACE(EMB_TRACE_WORK_WAIT, embk_thread_index(embk_cpu.current), 0u, EMB_EINVAL); + EMBK_API_MISUSE(EMB_API_ID_DWORK_CANCEL_SYNC, EMB_FAULT_API_ARGUMENT, EMB_EINVAL, 1u); + } + key = embk_lock_sched(); + (void)disarm_locked(dwork_of(dwork)); + embk_unlock_sched(key); + return embk_work_cancel_sync(EMB_API_ID_DWORK_CANCEL_SYNC, w, deadline, + EMB_TIMEOUT_IS_NO_WAIT(timeout)); +} + +#endif /* CONFIG_EMB_WORKQ && CONFIG_EMB_TIMER */ diff --git a/tests/conformance/test_workq.c b/tests/conformance/test_workq.c index 34fbb23..0f3aa41 100644 --- a/tests/conformance/test_workq.c +++ b/tests/conformance/test_workq.c @@ -227,3 +227,106 @@ EMB_TEST(workq_system_queue_runs_items) EMB_ASSERT_MARKS(7, 17); } #endif + +#if CONFIG_EMB_TIMER +/* Delayed work (KRN-WQ-003, KRN-WQ-007). */ +typedef struct { + emb_dwork_t dw; + uint32_t ran_at; + uint32_t sleep_ticks; + unsigned runs; +} dw_item_t; + +static dw_item_t dwi; + +static void dw_handler(emb_work_t *work) +{ + dw_item_t *it = EMB_CONTAINER_OF(work, dw_item_t, dw.work); + it->ran_at = EMB_TEST_NOW(); + it->runs++; + emb_test_mark(50 + (int)it->runs); + if (it->sleep_ticks != 0u) { + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(it->sleep_ticks))); + } +} + +static void make_dwork(void) +{ + emb_dwork_init(&dwi.dw, dw_handler); + dwi.ran_at = 0u; + dwi.sleep_ticks = 0u; + dwi.runs = 0u; +} + +EMB_TEST(dwork_runs_after_its_delay_and_schedule_is_idempotent) +{ + uint32_t t0; + EMB_TEST_REQ("KRN-WQ-003", "KRN-WQ-007"); + make(2u); + make_dwork(); + t0 = EMB_TEST_NOW(); + EMB_ASSERT_OK(emb_dwork_schedule(wq, &dwi.dw, EMB_TICKS(3))); + EMB_ASSERT_STATUS(emb_dwork_schedule(wq, &dwi.dw, EMB_TICKS(1)), EMB_EEXIST); /* unchanged */ + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(5))); + EMB_ASSERT_MARKS(51); + emb_test_check_elapsed(__LINE__, dwi.ran_at - t0, 3u); + EMB_ASSERT_OK(emb_workq_destroy(wq)); /* the fired delay released the queue */ +} + +EMB_TEST(dwork_reschedule_replaces_the_delay) +{ + uint32_t t0; + EMB_TEST_REQ("KRN-WQ-007"); + make(2u); + make_dwork(); + t0 = EMB_TEST_NOW(); + EMB_ASSERT_OK(emb_dwork_schedule(wq, &dwi.dw, EMB_TICKS(10))); + EMB_ASSERT_OK(emb_dwork_reschedule(wq, &dwi.dw, EMB_TICKS(2))); + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(12))); + EMB_ASSERT_MARKS(51); /* once, at the new deadline */ + emb_test_check_elapsed(__LINE__, dwi.ran_at - t0, 2u); + EMB_ASSERT_OK(emb_workq_destroy(wq)); +} + +EMB_TEST(dwork_cancel_reports_pending_for_a_delay_and_a_queued_item) +{ + emb_work_state_t st = EMB_WORK_IDLE; + EMB_TEST_REQ("KRN-WQ-007", "KRN-WQ-004"); + make(2u); + make_dwork(); + EMB_ASSERT_OK(emb_dwork_schedule(wq, &dwi.dw, EMB_TICKS(5))); + EMB_ASSERT_MISUSE(emb_workq_destroy(wq), EMB_EBUSY); /* the delay holds the queue */ + EMB_ASSERT_OK(emb_dwork_cancel(&dwi.dw, &st)); + EMB_ASSERT_EQ(st, EMB_WORK_PENDING); /* the delay stopped */ + EMB_ASSERT_OK(emb_dwork_cancel(&dwi.dw, &st)); + EMB_ASSERT_EQ(st, EMB_WORK_IDLE); + items[0].sleep_ticks = 4u; + EMB_ASSERT_OK(emb_work_submit(wq, &items[0].work)); /* keeps the queue's thread busy */ + EMB_ASSERT_OK(emb_dwork_schedule(wq, &dwi.dw, EMB_TICKS(1))); + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(2))); /* fired: queued behind item 1 */ + EMB_ASSERT_EQ(emb_work_state(&dwi.dw.work), EMB_WORK_PENDING); + EMB_ASSERT_STATUS(emb_dwork_reschedule(wq, &dwi.dw, EMB_TICKS(1)), EMB_EEXIST); + EMB_ASSERT_OK(emb_dwork_cancel(&dwi.dw, &st)); + EMB_ASSERT_EQ(st, EMB_WORK_PENDING); /* removed from the queue */ + EMB_ASSERT_OK(emb_workq_flush(wq, EMB_WAIT_FOREVER)); + EMB_ASSERT_MARKS(1, 11); /* only item 1 ran */ + EMB_ASSERT_EQ(dwi.runs, 0u); + EMB_ASSERT_OK(emb_workq_destroy(wq)); +} + +EMB_TEST(dwork_cancel_sync_waits_for_a_running_handler) +{ + EMB_TEST_REQ("KRN-WQ-007", "KRN-WQ-006"); + make(2u); + make_dwork(); + dwi.sleep_ticks = 3u; + EMB_ASSERT_OK(emb_dwork_schedule(wq, &dwi.dw, EMB_TICKS(1))); + EMB_ASSERT_OK(emb_thread_sleep(EMB_TICKS(2))); /* the handler runs and sleeps */ + EMB_ASSERT_EQ(emb_work_state(&dwi.dw.work), EMB_WORK_RUNNING); + EMB_ASSERT_STATUS(emb_dwork_cancel_sync(&dwi.dw, EMB_NO_WAIT), EMB_ETIMEDOUT); + EMB_ASSERT_OK(emb_dwork_cancel_sync(&dwi.dw, EMB_WAIT_FOREVER)); + EMB_ASSERT_EQ(emb_work_state(&dwi.dw.work), EMB_WORK_IDLE); + EMB_ASSERT_MARKS(51); + EMB_ASSERT_OK(emb_workq_destroy(wq)); +} +#endif diff --git a/tests/differential/diff_runner.c b/tests/differential/diff_runner.c index 92a4f8c..03584b6 100644 --- a/tests/differential/diff_runner.c +++ b/tests/differential/diff_runner.c @@ -18,6 +18,7 @@ * W=, a work queue whose thread the trace calls tid * H:;... a work item and its handler's operations * R=isr|q:;... a timer and its callback's operations + * K:;... a delayed work item and its handler's operations * I:;... an ISR body (give, resume, nset, start, eset, eclear, * and qsend, qfront, qrecv, qpeek with no wait) * Operations (t: -1 forever, 0 no wait, n ticks): @@ -27,7 +28,8 @@ * tdestroy tid | eset e bits | eclear e bits | ewait e mask all clear t | edestroy e | * cwait c m t | csignal c | cbcast c | cdestroy c | qsend q value t | qfront q value | * qrecv q t | qpeek q | qdestroy q | submit q w | wcancel w | wcsync w t | wflush q t | - * wqdestroy q | tstart t first period | tstop t | tsync t t + * wqdestroy q | tstart t first period | tstop t | tsync t t | dsched q w delay | + * dresched q w delay | dcancel w * A schedule entry fires right after the n-th trace event (or in idle, if that event never * comes): "isr@n" raises ISR i, "tick@n" advances virtual time by one tick. */ @@ -114,7 +116,9 @@ enum op_kind { OP_WQDESTROY, OP_TSTART, OP_TSTOP, - OP_TSYNC + OP_TSYNC, + OP_DSCHED, + OP_DCANCEL }; struct op { @@ -200,9 +204,13 @@ struct workq_desc { struct work_desc { long w; + bool delayed; struct program body; #if CONFIG_EMB_WORKQ emb_work_t work; +#if CONFIG_EMB_TIMER + emb_dwork_t dwork; +#endif #endif }; @@ -458,6 +466,15 @@ static void parse_op(char *text, struct op *op) } else if (strcmp(name, "tstop") == 0) { op->kind = OP_TSTOP; op->a = num(args[0]); + } else if (strcmp(name, "dsched") == 0 || strcmp(name, "dresched") == 0) { + op->kind = OP_DSCHED; + op->a = num(args[0]); + op->b = num(args[1]); + op->c = num(args[2]); + op->d = name[1] == 'r'; /* reschedule */ + } else if (strcmp(name, "dcancel") == 0) { + op->kind = OP_DCANCEL; + op->a = num(args[0]); } else if (strcmp(name, "tsync") == 0) { op->kind = OP_TSYNC; op->a = num(args[0]); @@ -589,12 +606,13 @@ static void parse_section(char *sec) attrs = p; q->tid = num(cut(&attrs, ',')); q->prio = (uint8_t)num(attrs); - } else if (kind == 'H') { + } else if (kind == 'H' || kind == 'K') { struct work_desc *w; if (work_count >= MAX_WORKS) { die("too many work items"); } w = &works[work_count++]; + w->delayed = kind == 'K'; w->w = num(cut(&p, ':')); parse_program(p, &w->body); } else if (kind == 'R') { @@ -746,16 +764,32 @@ static emb_workq_t workq_by_q(long q) return workqs[0].handle; } -static emb_work_t *work_by_w(long w) +/* The item a work id names: its own, or a delayed item's. */ +static emb_work_t *item_of(struct work_desc *d) +{ +#if CONFIG_EMB_TIMER + if (d->delayed) { + return &d->dwork.work; + } +#endif + return &d->work; +} + +static struct work_desc *work_desc_by_w(long w) { unsigned i; for (i = 0u; i < work_count; i++) { if (works[i].w == w) { - return &works[i].work; + return &works[i]; } } die("unknown work item id"); - return &works[0].work; + return &works[0]; +} + +static emb_work_t *work_by_w(long w) +{ + return item_of(work_desc_by_w(w)); } #endif @@ -1101,6 +1135,34 @@ static void run_ops(const struct program *prog, struct thread_desc *self) case OP_TSYNC: die("software timers are not configured"); break; +#endif +#if CONFIG_EMB_TIMER && CONFIG_EMB_WORKQ + case OP_DSCHED: { + struct work_desc *d = work_desc_by_w(op->b); + if (!d->delayed) { + die("dsched names an item that is not delayed"); + } + st = (op->d != 0) ? emb_dwork_reschedule(workq_by_q(op->a), &d->dwork, + EMB_TICKS((emb_tick_t)op->c)) + : emb_dwork_schedule(workq_by_q(op->a), &d->dwork, + EMB_TICKS((emb_tick_t)op->c)); + break; + } + case OP_DCANCEL: { + struct work_desc *d = work_desc_by_w(op->a); + emb_work_state_t was = EMB_WORK_IDLE; + if (!d->delayed) { + die("dcancel names an item that is not delayed"); + } + st = emb_dwork_cancel(&d->dwork, &was); + data = was; + break; + } +#else + case OP_DSCHED: + case OP_DCANCEL: + die("delayed work is not configured"); + break; #endif default: die("bad operation kind"); @@ -1130,7 +1192,14 @@ static void timer_callback(emb_timer_t timer, void *arg) #if CONFIG_EMB_WORKQ static void work_handler(emb_work_t *work) { - run_ops(&EMB_CONTAINER_OF(work, struct work_desc, work)->body, NULL); /* results unrecorded */ + unsigned i; + for (i = 0u; i < work_count; i++) { + if (item_of(&works[i]) == work) { + run_ops(&works[i].body, NULL); /* results unrecorded */ + return; + } + } + die("unknown work item"); } #endif @@ -1205,7 +1274,7 @@ static void dump(const char *end, unsigned long e0, unsigned long e1) #endif #if CONFIG_EMB_WORKQ for (i = 0u; i < work_count; i++) { - (void)printf("work %ld %u\n", works[i].w, (unsigned)emb_work_state(&works[i].work)); + (void)printf("work %ld %u\n", works[i].w, (unsigned)emb_work_state(item_of(&works[i]))); } #endif #if CONFIG_EMB_MSGQ @@ -1336,7 +1405,15 @@ static void construct(void) EMB_CHECK(emb_workq_init(&workqs[i].storage, &attr, &workqs[i].handle)); } for (i = 0u; i < work_count; i++) { - emb_work_init(&works[i].work, work_handler); + if (works[i].delayed) { +#if CONFIG_EMB_TIMER + emb_dwork_init(&works[i].dwork, work_handler); +#else + die("delayed work is not configured"); +#endif + } else { + emb_work_init(&works[i].work, work_handler); + } } #else if (workq_count != 0u || work_count != 0u) { diff --git a/tools/fault/api-ids.json b/tools/fault/api-ids.json index 4ce2df8..73acc67 100644 --- a/tools/fault/api-ids.json +++ b/tools/fault/api-ids.json @@ -817,6 +817,42 @@ "name": "emb_timer_get_counts", "header": "timer.h", "state": "active" + }, + { + "id": 137, + "name": "emb_dwork_init", + "header": "work.h", + "state": "active" + }, + { + "id": 138, + "name": "emb_dwork_schedule", + "header": "work.h", + "state": "active" + }, + { + "id": 139, + "name": "emb_dwork_schedule_at", + "header": "work.h", + "state": "active" + }, + { + "id": 140, + "name": "emb_dwork_reschedule", + "header": "work.h", + "state": "active" + }, + { + "id": 141, + "name": "emb_dwork_cancel", + "header": "work.h", + "state": "active" + }, + { + "id": 142, + "name": "emb_dwork_cancel_sync", + "header": "work.h", + "state": "active" } ] } diff --git a/tools/fault/api-registry.json b/tools/fault/api-registry.json index d6b898f..b618c6a 100644 --- a/tools/fault/api-registry.json +++ b/tools/fault/api-registry.json @@ -816,6 +816,42 @@ "name": "emb_timer_get_counts", "header": "timer.h", "state": "active" + }, + { + "id": 137, + "name": "emb_dwork_init", + "header": "work.h", + "state": "active" + }, + { + "id": 138, + "name": "emb_dwork_schedule", + "header": "work.h", + "state": "active" + }, + { + "id": 139, + "name": "emb_dwork_schedule_at", + "header": "work.h", + "state": "active" + }, + { + "id": 140, + "name": "emb_dwork_reschedule", + "header": "work.h", + "state": "active" + }, + { + "id": 141, + "name": "emb_dwork_cancel", + "header": "work.h", + "state": "active" + }, + { + "id": 142, + "name": "emb_dwork_cancel_sync", + "header": "work.h", + "state": "active" } ] } diff --git a/tools/model/README.md b/tools/model/README.md index 11491aa..109464f 100644 --- a/tools/model/README.md +++ b/tools/model/README.md @@ -10,7 +10,7 @@ The model is written so that it can be read next to the specification: `embmodel |---|---| | `embmodel/kernel.py` | State (`Thread`, `WaitQueue`, `Semaphore`, `Mutex`, `Kernel`), the static op types (`Take`, `Give`, `Lock`, `Unlock`, `NotifySet`, `NotifyWait`, `Start`, `Join`, `Sleep`, `Yield`, `SetPrio`, `Suspend`, `Resume`, `Cancel`, `Destroy`, `SchedLock`, `SchedUnlock`, `Exit`), the wait protocol, effective priority with `effective()` and `propagate()`, interrupts and ticks, invariants | | `embmodel/explore.py` | Exhaustive depth-first exploration of every interleaving at section granularity; seeded random walks | -| `tests/scenarios.py` | The scenario catalogue: the SPEC-004 §15 worked example and the wait-protocol scenarios (`ALL`), plus the SPEC-005 §15 mutex scenarios (`MUTEX`): classic inversion, nested chain with timeouts, deadlock in both orders, owner death with and without a waiter, ceiling, waiter priority change, cancellation of a waiter, recursion, two-waiter hand-off; plus the SPEC-006 §11 notification scenarios (`NOTIFY`): set in the window, level semantics, ALL across two sets, one thread bound to two semaphores; plus the SPEC-008 §15 lifecycle scenarios (`JOIN`): join before and after exit, join with timeout, second joiner refused, canceled joiner, start of an inactive thread, exit while owning a mutex with a joiner; plus the binding lifetime scenarios (`BINDING`, ADR-046), event flags (`EVENT`, SPEC-005 §5), condition variables (`COND`, SPEC-005 §6) and message queues (`MSGQ`, SPEC-007 §2, modeled before their implementation per KRN-IPC-022): FIFO order with a full queue, receiver hand-off, send_front and peek, ISR send and receive with `EMB_NO_WAIT`, sender timeout and cancellation, destroy of both sides, the empty-to-nonempty binding; plus work queues (`WORKQ`, SPEC-006 §4), whose queue thread drains a FIFO of items whose handlers run model operations: FIFO order and idempotent submit, resubmission while running, cancel states, flush with a timeout and while a handler runs, ISR submit, destroy joining the queue thread; plus software timers (`TIMER`, SPEC-003 §7): one-shot and periodic ISR-context timers, a thread timeout and a timer due together, a work-queue callback, stop of a running callback and `stop_sync`, overruns from a long callback. A scenario `horizon` stops time from advancing while a thread could run once a periodic timer has run that long, as the runner does | +| `tests/scenarios.py` | The scenario catalogue: the SPEC-004 §15 worked example and the wait-protocol scenarios (`ALL`), plus the SPEC-005 §15 mutex scenarios (`MUTEX`): classic inversion, nested chain with timeouts, deadlock in both orders, owner death with and without a waiter, ceiling, waiter priority change, cancellation of a waiter, recursion, two-waiter hand-off; plus the SPEC-006 §11 notification scenarios (`NOTIFY`): set in the window, level semantics, ALL across two sets, one thread bound to two semaphores; plus the SPEC-008 §15 lifecycle scenarios (`JOIN`): join before and after exit, join with timeout, second joiner refused, canceled joiner, start of an inactive thread, exit while owning a mutex with a joiner; plus the binding lifetime scenarios (`BINDING`, ADR-046), event flags (`EVENT`, SPEC-005 §5), condition variables (`COND`, SPEC-005 §6) and message queues (`MSGQ`, SPEC-007 §2, modeled before their implementation per KRN-IPC-022): FIFO order with a full queue, receiver hand-off, send_front and peek, ISR send and receive with `EMB_NO_WAIT`, sender timeout and cancellation, destroy of both sides, the empty-to-nonempty binding; plus work queues (`WORKQ`, SPEC-006 §4), whose queue thread drains a FIFO of items whose handlers run model operations: FIFO order and idempotent submit, resubmission while running, cancel states, flush with a timeout and while a handler runs, ISR submit, destroy joining the queue thread; plus software timers (`TIMER`, SPEC-003 §7): one-shot and periodic ISR-context timers, a thread timeout and a timer due together, a work-queue callback, stop of a running callback and `stop_sync`, overruns from a long callback; and delayed work (`DWORK`): a delay that runs once, idempotent schedule, reschedule and cancel, ISR scheduling. A scenario `horizon` stops time from advancing while a thread could run once a periodic timer has run that long, as the runner does | | `tests/test_scenarios.py` | Specific interleavings with expected results | | `tests/test_explore.py` | Exhaustive exploration of every scenario plus outcome properties | | `tests/test_random.py` | Random walks over generated scenarios; a Hypothesis-generated scenario/seed campaign when installed | diff --git a/tools/model/bridge.py b/tools/model/bridge.py index d981d8e..4af5bb6 100644 --- a/tools/model/bridge.py +++ b/tools/model/bridge.py @@ -32,7 +32,7 @@ Destroy, EventClear, EventDestroy, EventSet, EventWait, Exit, Give, Join, Lock, ModelError, MsgDestroy, MsgPeek, MsgRecv, MsgSend, MsgSendFront, Submit, WorkCancel, WorkCancelSync, WorkFlush, WorkqDestroy, - TimerStart, TimerStop, TimerStopSync, + TimerStart, TimerStop, TimerStopSync, DworkSchedule, DworkCancel, DWORK_TIMER_BASE, NONWAIT_RESULT_OPS, NotifySet, NotifyWait, Policy, Reason, Result, Resume, Scenario, SchedLock, SchedUnlock, SetPrio, Sleep, Start, Suspend, Take, ThreadDestroy, UnbindNotify, Unlock, Yield, build, FOREVER, NO_WAIT, TIMER_WORK_BASE) @@ -186,6 +186,10 @@ def encode_op(op) -> str: return f"tstop {op.timer}" if isinstance(op, TimerStopSync): return f"tsync {op.timer} {_t(op.timeout)}" + if isinstance(op, DworkSchedule): + return f"{'dresched' if op.replace else 'dsched'} {op.q} {op.w} {op.delay}" + if isinstance(op, DworkCancel): + return f"dcancel {op.w}" raise ValueError(f"unknown op {op!r}") @@ -223,7 +227,8 @@ def encode_scenario(sc: Scenario) -> str: for q, cfg in sc.workqs.items(): parts.append(f"W{q}={cfg['tid']},{cfg['prio']}") for w, cfg in sc.works.items(): - parts.append(f"H{w}:" + ";".join(encode_op(op) for op in cfg.get("body", ()))) + kind = "K" if cfg.get("delayed") else "H" # K: an emb_dwork_t + parts.append(f"{kind}{w}:" + ";".join(encode_op(op) for op in cfg.get("body", ()))) for t, cfg in sc.timers.items(): mode = "isr" if cfg.get("isr") else f"q{cfg['q']}" parts.append(f"R{t}={mode}:" + ";".join(encode_op(op) for op in cfg.get("body", ()))) @@ -657,7 +662,7 @@ def finish(self, k, path): if kres_mapped != result: raise Divergence(f"thread {tid} op {op_index} {op!r}: model {result.name}, kernel status {kstatus}") if isinstance(op, (NotifyWait, Join, Lock, EventWait, CondWait, MsgRecv, MsgPeek, WorkCancel, - TimerStop)) \ + TimerStop, DworkCancel)) \ and result == Result.SATISFIED \ and kdata != data: raise Divergence(f"thread {tid} op {op_index} {op!r}: model data {data}, kernel data {kdata}") @@ -681,6 +686,8 @@ def finish(self, k, path): if eid in r.evflags and not e.destroyed and r.evflags[eid] != e.bits: raise Divergence(f"event flags {eid}: kernel bits {r.evflags[eid]:#x}, model {e.bits:#x}") for tm, t in k.timers.items(): + if tm >= DWORK_TIMER_BASE: + continue # a delayed item's internal timer: its events cover it if r.timers.get(tm) != (t.expiries, t.overruns, t.deadline is not None): raise Divergence(f"timer {tm}: kernel (expiries, overruns, armed) {r.timers.get(tm)}, " f"model {(t.expiries, t.overruns, t.deadline is not None)}") diff --git a/tools/model/embmodel/__init__.py b/tools/model/embmodel/__init__.py index 1228c4d..def5dbf 100644 --- a/tools/model/embmodel/__init__.py +++ b/tools/model/embmodel/__init__.py @@ -11,7 +11,7 @@ from .kernel import ( # noqa: F401 BindNotify, Cancel, CondBroadcast, MsgDestroy, MsgPeek, MsgRecv, MsgSend, MsgSendFront, Submit, WorkCancel, WorkCancelSync, WorkFlush, WorkqDestroy, K_WORK, WORK_IDLE, WORK_PENDING, WORK_RUNNING, - TimerStart, TimerStop, TimerStopSync, TIMER_WORK_BASE, + TimerStart, TimerStop, TimerStopSync, TIMER_WORK_BASE, DworkSchedule, DworkCancel, DWORK_TIMER_BASE, NONWAIT_DATA_OPS, CondDestroy, CondSignal, CondWait, Destroy, EventClear, EventDestroy, EventSet, EventWait, Exit, Give, Join, NONWAIT_RESULT_OPS, ThreadDestroy, UnbindNotify, Kernel, Lock, ModelError, Mutex, NotifySet, NotifyWait, Policy, Protocol, Reason, Result, Resume, Start, SchedLock, SchedUnlock, Scenario, SetPrio, Sleep, Suspend, Take, Thread, diff --git a/tools/model/embmodel/kernel.py b/tools/model/embmodel/kernel.py index 43a59de..3e0445f 100644 --- a/tools/model/embmodel/kernel.py +++ b/tools/model/embmodel/kernel.py @@ -69,6 +69,7 @@ class Protocol(IntEnum): # SPEC-005 §3 WORK_QID_BASE = 500 # work queues: the completion queue of queue q is 500 + q K_WORK = 1 << 31 # EMB_NOTIFY_K_WORK with 32-bit notification words (SPEC-006 §2.1) TIMER_WORK_BASE = 1000 # the work item embedded in timer t is item 1000 + t +DWORK_TIMER_BASE = 2000 # the internal timer of delayed item w is timer 2000 + w WORK_IDLE, WORK_PENDING, WORK_RUNNING = 0, 1, 2 # emb_work_state_t; PENDING is "queued" @@ -359,6 +360,21 @@ class TimerStopSync: timeout: Optional[int] = FOREVER +@dataclass(frozen=True) +class DworkSchedule: + """emb_dwork_schedule(q, w, delay), or emb_dwork_reschedule() when replace is set.""" + q: int + w: int + delay: int + replace: bool = False + + +@dataclass(frozen=True) +class DworkCancel: + """emb_dwork_cancel(w): stop the delay or unlink the queued item; data is the state found.""" + w: int + + def event_satisfying(bits: int, mask: int, all_: bool) -> int: got = bits & mask if all_: @@ -366,10 +382,11 @@ def event_satisfying(bits: int, mask: int, all_: bool) -> int: return got -NONWAIT_RESULT_OPS = NONWAIT_RESULT_OPS + (Submit, TimerStart) +NONWAIT_RESULT_OPS = NONWAIT_RESULT_OPS + (Submit, TimerStart, DworkSchedule) NONWAIT_DATA_OPS = (MsgSendFront, MsgPeek) # record a result and data, never wait ISR_SAFE_OPS = (Give, Resume, NotifySet, Start, EventSet, EventClear, # SPEC-001 §5.2: signal-type operations are `thread isr` - MsgSendFront, MsgPeek, MsgSend, MsgRecv, Submit, WorkCancel, TimerStart, TimerStop) + MsgSendFront, MsgPeek, MsgSend, MsgRecv, Submit, WorkCancel, TimerStart, TimerStop, + DworkSchedule, DworkCancel) ISR_NO_WAIT_ONLY = (MsgSend, MsgRecv) # with EMB_NO_WAIT only (SPEC-007 §1) @@ -520,10 +537,12 @@ class Timer: overruns: int = 0 armed_in_pass: bool = False # armed by this expiry pass: the next pass expires it in_callback: bool = False # an ISR_CONTEXT callback runs + dwork: Optional[int] = None # a delayed item's internal timer: the item it submits + target: Optional[int] = None # and the queue, while the delay runs def key(self): return (self.tmid, self.deadline, self.seq, self.period, self.origin, self.expiries, self.overruns, - self.armed_in_pass, self.in_callback) + self.armed_in_pass, self.in_callback, self.target) @dataclass @@ -604,6 +623,9 @@ def build(sc: Scenario) -> "Kernel": for w, cfg in sc.works.items(): k.works[w] = Work(wid=w) k.work_bodies[w] = tuple(cfg.get("body", ())) + if cfg.get("delayed"): # emb_dwork_t: an internal timer + k.timers[DWORK_TIMER_BASE + w] = Timer(tmid=DWORK_TIMER_BASE + w, isr=True, dwork=w) + k.timer_bodies[DWORK_TIMER_BASE + w] = () for tm, cfg in sc.timers.items(): isr = bool(cfg.get("isr", False)) k.timers[tm] = Timer(tmid=tm, isr=isr, q=None if isr else cfg["q"]) @@ -1527,7 +1549,8 @@ def _op_workq_destroy(self, t: Thread, op: WorkqDestroy) -> bool: if wq.destroyed: self._finish(t, Result.STALE) return True - busy = wq.fifo or wq.running is not None or wq.stopping or self.queues[wq.done_qid].waiters + busy = (wq.fifo or wq.running is not None or wq.stopping or self.queues[wq.done_qid].waiters + or any((not tm.isr and tm.q == wq.q) or tm.target == wq.q for tm in self.timers.values())) if t.tid == wq.tid or busy: self._finish(t, Result.EPERM) # misuse: a fault when checked return True @@ -1807,6 +1830,8 @@ def step_thread(self) -> bool: res = self._op_submit(op) elif isinstance(op, TimerStart): res = self._op_timer_start(op) + elif isinstance(op, DworkSchedule): + res = self._op_dwork_schedule(op) else: res = self._op_thread_destroy(op) self._record(t, res) @@ -1816,6 +1841,9 @@ def step_thread(self) -> bool: elif isinstance(op, TimerStop): self._record(t, Result.SATISFIED, self._op_timer_stop(op)) self._advance(t) + elif isinstance(op, DworkCancel): + self._record(t, Result.SATISFIED, self._op_dwork_cancel(op)) + self._advance(t) elif isinstance(op, TimerStopSync): if self._op_timer_stop_sync(t, op): self.reschedule_if_needed() @@ -1902,6 +1930,10 @@ def _isr_op(self, op): self._op_timer_start(op) elif isinstance(op, TimerStop): self._op_timer_stop(op) + elif isinstance(op, DworkSchedule): + self._op_dwork_schedule(op) + elif isinstance(op, DworkCancel): + self._op_dwork_cancel(op) def tick(self): self.now += 1 @@ -1957,6 +1989,11 @@ def _timer_expired(self, tm: Timer): else: tm.deadline = None self._event("timer_expire", tm.tmid) + if tm.dwork is not None: # the O(1) submit (KRN-WQ-007) + if tm.target is not None: + q, tm.target = tm.target, None + self._op_submit(Submit(q, tm.dwork)) + return if tm.isr: tm.in_callback, self.in_expiry = True, True for op in self.timer_bodies[tm.tmid]: @@ -1969,6 +2006,28 @@ def _timer_expired(self, tm: Timer): else: self._op_submit(Submit(tm.q, w.wid)) + def _op_dwork_schedule(self, op: DworkSchedule) -> Result: + wq, w, tm = self.workqs[op.q], self.works[op.w], self.timers[DWORK_TIMER_BASE + op.w] + if wq.destroyed: + return Result.STALE + if wq.stopping: + return Result.STATE + if w.state == WORK_PENDING or (tm.target is not None and not op.replace): + return Result.EXIST # nothing changes + if w.state == WORK_RUNNING and w.queue != op.q: + return Result.BUSY + tm.deadline, tm.target = None, op.q + self._timer_arm(tm, self.now + op.delay) + self._event("timer_start", tm.tmid) + return Result.SATISFIED + + def _op_dwork_cancel(self, op: DworkCancel) -> int: + tm = self.timers[DWORK_TIMER_BASE + op.w] + if tm.target is not None: + tm.deadline, tm.target = None, None + return WORK_PENDING # the delay stopped + return self._op_work_cancel(WorkCancel(op.w)) + def _op_timer_start(self, op: TimerStart) -> Result: tm = self.timers[op.timer] tm.deadline = None @@ -2050,6 +2109,8 @@ def check_invariants(self): for tm in self.timers.values(): # SPEC-003 §7.4, KRN-TIM-015 if tm.period and tm.deadline is not None and (tm.deadline - tm.origin) % tm.period != 0: raise ModelError(f"timer {tm.tmid}: deadline {tm.deadline} drifted off {tm.origin} + k * {tm.period}") + if tm.dwork is not None and (tm.target is None) != (tm.deadline is None): + raise ModelError(f"delayed item {tm.dwork}: target {tm.target} but deadline {tm.deadline}") if tm.deadline is not None and tm.armed_in_pass: raise ModelError(f"timer {tm.tmid}: the pass marker survived its expiry pass") for w in self.works.values(): # SPEC-006 §4.3 diff --git a/tools/model/evidence.py b/tools/model/evidence.py index 2feb7ca..ebb12af 100644 --- a/tools/model/evidence.py +++ b/tools/model/evidence.py @@ -29,7 +29,7 @@ bridge.CondWait, bridge.CondSignal, bridge.CondBroadcast, bridge.CondDestroy, bridge.MsgSend, bridge.MsgSendFront, bridge.MsgRecv, bridge.MsgPeek, bridge.MsgDestroy, bridge.Submit, bridge.WorkCancel, bridge.WorkCancelSync, bridge.WorkFlush, bridge.WorkqDestroy, - bridge.TimerStart, bridge.TimerStop, bridge.TimerStopSync)} + bridge.TimerStart, bridge.TimerStop, bridge.TimerStopSync, bridge.DworkSchedule, bridge.DworkCancel)} def scenario_data(sc): @@ -48,7 +48,8 @@ def program(ops): data["msgqs"] = list(sc.msgqs.items()) if sc.workqs: # and work queues with their items' handlers data["workqs"] = list(sc.workqs.items()) - data["works"] = [(w, dict(body=program(cfg.get("body", ())))) for w, cfg in sc.works.items()] + data["works"] = [(w, dict({k: v for k, v in cfg.items() if k != "body"}, body=program(cfg.get("body", ())))) + for w, cfg in sc.works.items()] if sc.timers: # and software timers with their callbacks data["timers"] = [(t, dict({k: v for k, v in cfg.items() if k != "body"}, body=program(cfg.get("body", ())))) for t, cfg in sc.timers.items()] @@ -98,7 +99,7 @@ def objects(items, enum_field=None, enum_type=None): conds=objects(raw.get("conds", []), "policy", bridge.Policy), msgqs=objects(raw.get("msgqs", []), "policy", bridge.Policy), workqs=objects(raw.get("workqs", [])), - works={w: dict(body=program(cfg["body"])) for w, cfg in objects(raw.get("works", [])).items()}, + works={w: dict(cfg, body=program(cfg["body"])) for w, cfg in objects(raw.get("works", [])).items()}, timers={t: dict(cfg, body=program(cfg["body"])) for t, cfg in objects(raw.get("timers", [])).items()}, horizon=raw.get("horizon"), diff --git a/tools/model/tests/scenarios.py b/tools/model/tests/scenarios.py index fb3327e..b09d51d 100644 --- a/tools/model/tests/scenarios.py +++ b/tools/model/tests/scenarios.py @@ -739,3 +739,43 @@ def timer_periodic_overrun_on_long_callback(): TIMER = [timer_one_shot_isr_context, timer_periodic_isr_stopped_by_thread, timer_equal_deadlines_thread_first, timer_work_queue_callback, timer_stop_running_callback_and_stop_sync, timer_periodic_overrun_on_long_callback] ALL = ALL + TIMER + + +# --------------------------------------------------------------------------------------- +# M2-09: delayed work (SPEC-006 §4.3, KRN-WQ-003, KRN-WQ-007): an internal ISR_CONTEXT timer +# submits the item to the queue named at schedule time +# --------------------------------------------------------------------------------------- +from embmodel import DworkCancel, DworkSchedule # noqa: E402 + + +def dwork_runs_after_its_delay(): + """A scheduled item runs after its delay; a second schedule while it waits is EEXIST.""" + return Scenario(name="dwork_runs_after_its_delay", + threads={1: (1, (DworkSchedule(0, 0, 2), DworkSchedule(0, 0, 1), Take(0)))}, + sems={0: dict(count=0)}, + workqs={0: dict(tid=9, prio=2)}, + works={0: dict(body=(Give(0),), delayed=True)}) + + +def dwork_reschedule_and_cancel(): + """reschedule replaces the delay; cancel reports PENDING for a running delay, then IDLE.""" + return Scenario(name="dwork_reschedule_and_cancel", + threads={1: (1, (DworkSchedule(0, 0, 5), DworkSchedule(0, 0, 1, replace=True), Take(0), + DworkSchedule(0, 0, 3), DworkCancel(0), DworkCancel(0)))}, + sems={0: dict(count=0)}, + workqs={0: dict(tid=9, prio=2)}, + works={0: dict(body=(Give(0),), delayed=True)}) + + +def dwork_isr_schedule(): + """An ISR schedules the item at any point; the thread's timed take sees it or times out.""" + return Scenario(name="dwork_isr_schedule", + threads={1: (1, (Take(0, 4),))}, + sems={0: dict(count=0)}, + workqs={0: dict(tid=9, prio=2)}, + works={0: dict(body=(Give(0),), delayed=True)}, + isrs=((DworkSchedule(0, 0, 1),),)) + + +DWORK = [dwork_runs_after_its_delay, dwork_reschedule_and_cancel, dwork_isr_schedule] +ALL = ALL + DWORK diff --git a/tools/model/tests/test_explore.py b/tools/model/tests/test_explore.py index 630592f..0074466 100644 --- a/tools/model/tests/test_explore.py +++ b/tools/model/tests/test_explore.py @@ -570,3 +570,43 @@ def test_closed_form_catch_up(self): k.now = 9 # 4, 6 and 8 were missed k._expire_due() self.assertEqual((tm.expiries, tm.overruns, tm.deadline), (1, 3, 10)) + + +class DelayedWork(unittest.TestCase): + """M2-09 delayed work (KRN-WQ-003, KRN-WQ-007): runs after the delay, idempotent schedule, + reschedule, cancel states, ISR scheduling; the delay's target and its timer agree.""" + + @staticmethod + def outcomes(make): + return [(dict(dict(o)["threads"]), {w: (s, n) for w, s, n in dict(o)["works"]}) + for o in explore(make()).outcomes] + + def test_every_dwork_scenario_holds_invariants(self): + for make in S.DWORK: + sc = make() + with self.subTest(scenario=sc.name): + self.assertGreater(explore(sc).terminals, 0) + + def test_runs_after_delay_and_schedule_is_idempotent(self): + seen = set() + for d, w in self.outcomes(S.dwork_runs_after_its_delay): + seen.add(d[1][1][1]) # EXIST, unless it already ran + self.assertEqual(d[1][2][1], Result.SATISFIED) + self.assertEqual(w[0][0], 0) + self.assertIn(Result.EXIST, seen) + + def test_reschedule_and_cancel(self): + seen = set() + for d, w in self.outcomes(S.dwork_reschedule_and_cancel): + res = {r[0]: (r[1], r[2]) for r in d[1]} + self.assertEqual(res[1][0], Result.SATISFIED) # replaced or rescheduled + self.assertEqual(res[2][0], Result.SATISFIED) + seen.add((res[4][1], res[5][1])) + self.assertEqual(w[0][0], 0) # idle at the end + self.assertIn((1, 0), seen) # PENDING, then IDLE + + def test_isr_schedule(self): + seen = set() + for d, w in self.outcomes(S.dwork_isr_schedule): + seen.add(d[1][0][1]) + self.assertIn(Result.SATISFIED, seen) From bb60e2ff17d652114e6ff52f4c319aa17859c945 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Fri, 30 Oct 2026 22:42:47 +0100 Subject: [PATCH 22/30] test(m2-10): combined scenarios, a coverage preset and the campaign runner - The COMBINED scenario group meets several mechanisms at section boundaries, with ISR and tick placement left to the explorer and the bridge: - a suspended, canceled and resumed message queue receiver against an ISR send; - an inheriting owner's priority change against an event wait's timeout and an ISR set; - a timed take against a give and destroy at the same ticks; - one thread bound to a message queue and to event flags; - an ISR-context timer feeding a queue a work handler reads while the thread flushes and stops it; - an ISR cancel racing a delayed item's expiry. - native-coverage preset: GCC with --coverage at -O0. The native port dumps the gcov counters before _exit(), which skips the exit-time flush. - tools/coverage/report.py: kernel statement and branch coverage from gcov or llvm-cov gcov, with file exclusions and their reasons. - tools/model/campaign.py: runs the bridge over runners and seeds plus the timer regressions and writes a JSON summary with the build identities. Signed-off-by: teo1747 --- CMakePresets.json | 22 ++++++ arch/native/native_port.c | 7 ++ cmake/toolchains/native-coverage.cmake | 12 +++ tools/coverage/exclusions.json | 1 + tools/coverage/report.py | 105 +++++++++++++++++++++++++ tools/model/campaign.py | 75 ++++++++++++++++++ tools/model/tests/scenarios.py | 70 +++++++++++++++++ tools/model/tests/test_explore.py | 37 +++++++++ 8 files changed, 329 insertions(+) create mode 100644 cmake/toolchains/native-coverage.cmake create mode 100644 tools/coverage/exclusions.json create mode 100644 tools/coverage/report.py create mode 100644 tools/model/campaign.py diff --git a/CMakePresets.json b/CMakePresets.json index 1669535..412cab4 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -82,6 +82,17 @@ "EMB_PROFILE": "base" } }, + { + "name": "native-coverage", + "displayName": "native, GCC, gcov coverage, checked", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/native-coverage", + "toolchainFile": "${sourceDir}/cmake/toolchains/native-coverage.cmake", + "cacheVariables": { + "EMB_BOARD": "native", + "EMB_PROFILE": "base" + } + }, { "name": "avr-uno", "displayName": "Arduino Uno, avr-gcc, tiny profile, checked", @@ -283,6 +294,10 @@ "name": "native-tsan", "configurePreset": "native-tsan" }, + { + "name": "native-coverage", + "configurePreset": "native-coverage" + }, { "name": "avr-uno", "configurePreset": "avr-uno" @@ -402,6 +417,13 @@ "outputOnFailure": true } }, + { + "name": "native-coverage", + "configurePreset": "native-coverage", + "output": { + "outputOnFailure": true + } + }, { "name": "avr-uno", "configurePreset": "avr-uno", diff --git a/arch/native/native_port.c b/arch/native/native_port.c index e0c0169..92f87e0 100644 --- a/arch/native/native_port.c +++ b/arch/native/native_port.c @@ -565,8 +565,15 @@ void emb_arch_breakpoint(void) { } +#ifdef EMB_NATIVE_COVERAGE +void __gcov_dump(void); /* the gcov runtime: _exit() skips its exit-time flush */ +#endif + void emb_native_exit(int code) { (void)fflush(NULL); +#ifdef EMB_NATIVE_COVERAGE + __gcov_dump(); +#endif _exit(code); } diff --git a/cmake/toolchains/native-coverage.cmake b/cmake/toolchains/native-coverage.cmake new file mode 100644 index 0000000..fef142d --- /dev/null +++ b/cmake/toolchains/native-coverage.cmake @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Junior Deogracias +# Host build with gcov instrumentation for statement and branch coverage (M2-10); +# tools/coverage/report.py turns the counters into the report. +set(CMAKE_C_COMPILER gcc) +set(EMB_TOOLCHAIN_C_FLAGS -pthread -g -O0 --coverage) +set(EMB_TOOLCHAIN_LINK_FLAGS -pthread --coverage) +set(EMB_TOOLCHAIN_DEFINES _POSIX_C_SOURCE=200809L _DEFAULT_SOURCE EMB_NATIVE_COVERAGE=1) + +if(APPLE) + list(APPEND EMB_TOOLCHAIN_LINK_FLAGS -Wl,-no_warn_duplicate_libraries) +endif() diff --git a/tools/coverage/exclusions.json b/tools/coverage/exclusions.json new file mode 100644 index 0000000..69b685d --- /dev/null +++ b/tools/coverage/exclusions.json @@ -0,0 +1 @@ +{"files": {}} diff --git a/tools/coverage/report.py b/tools/coverage/report.py new file mode 100644 index 0000000..daf635f --- /dev/null +++ b/tools/coverage/report.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Junior Deogracias +"""Statement and branch coverage of the kernel from a gcov-instrumented build (M2-10). + +Runs gcov (or `llvm-cov gcov`) on every kernel translation unit of a build made with the +native-coverage preset after its tests ran, and reports per-file and total line and +branch coverage. Only kernel .c files count: header inline functions are reported per +translation unit by gcov and cannot be merged from its summaries. Files listed in the +exclusions file are reported with their reason and left out of the totals. +""" +import argparse +import json +from pathlib import Path +import re +import shlex +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[2] +BLOCK = re.compile(r"File '([^']+)'\n(?:Lines executed:([\d.]+)% of (\d+)\n)?" + r"(?:Branches executed:[\d.]+% of \d+\nTaken at least once:([\d.]+)% of (\d+)\n)?") + + +def gcov_summary(gcov, gcno): + """{source: (lines, lines_hit, branches, branches_taken)} for one translation unit.""" + out = subprocess.run(gcov + ["-b", "-c", "-n", gcno.name], cwd=gcno.parent, capture_output=True, + text=True) + result = {} + for m in BLOCK.finditer(out.stdout): + src, lpct, lines, bpct, branches = m.groups() + lines, branches = int(lines or 0), int(branches or 0) + hit = round(float(lpct or 0) * lines / 100) + taken = round(float(bpct or 0) * branches / 100) + result[str(Path(src).resolve())] = (lines, hit, branches, taken) + return result + + +def collect(build, gcov): + files = {} + for gcno in sorted((build / "kernel").rglob("*.gcno")): + for src, counts in gcov_summary(gcov, gcno).items(): + path = Path(src) + if path.suffix != ".c" or not path.is_relative_to(ROOT / "kernel"): + continue + rel = str(path.relative_to(ROOT)) + files[rel] = dict(lines=counts[0], lines_hit=counts[1], branches=counts[2], + branches_taken=counts[3], executed=gcno.with_suffix(".gcda").exists()) + return files + + +def pct(n, d): + return 100.0 * n / d if d else 100.0 + + +def render(files, exclusions, title): + rows = ["| file | lines | branches | note |", "|---|---:|---:|---|"] + tl = th = tb = tt = 0 + for rel, f in sorted(files.items()): + note = exclusions.get(rel, "") + if not note: + tl += f["lines"] + th += f["lines_hit"] + tb += f["branches"] + tt += f["branches_taken"] + elif not f["executed"]: + note = note or "not executed" + rows.append(f"| `{rel}` | {pct(f['lines_hit'], f['lines']):.1f}% of {f['lines']} | " + f"{pct(f['branches_taken'], f['branches']):.1f}% of {f['branches']} | " + f"{'excluded: ' + note if note else ''} |") + total = dict(lines=tl, lines_hit=th, branches=tb, branches_taken=tt, + line_pct=round(pct(th, tl), 1), branch_pct=round(pct(tt, tb), 1)) + md = [f"# {title}", "", f"Kernel statement (line) coverage **{total['line_pct']}%** of {tl} lines; " + f"branch coverage (taken at least once) **{total['branch_pct']}%** of {tb} branches, " + "over the files not excluded.", ""] + rows + [""] + return "\n".join(md), total + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--build", type=Path, default=ROOT / "build/native-coverage") + ap.add_argument("--gcov", default="gcov", help='the gcov command, e.g. "xcrun llvm-cov gcov"') + ap.add_argument("--exclusions", type=Path, default=ROOT / "tools/coverage/exclusions.json") + ap.add_argument("--json", type=Path) + ap.add_argument("--markdown", type=Path) + ap.add_argument("--title", default="Kernel coverage") + args = ap.parse_args(argv) + exclusions = json.loads(args.exclusions.read_text())["files"] if args.exclusions.is_file() else {} + files = collect(args.build.resolve(), shlex.split(args.gcov)) + if not files: + print("coverage: no kernel coverage data; build and test the native-coverage preset first", + file=sys.stderr) + return 2 + md, total = render(files, exclusions, args.title) + if args.json: + args.json.write_text(json.dumps(dict(total=total, files=files, exclusions=exclusions), indent=2) + "\n") + if args.markdown: + args.markdown.write_text(md) + print(f"coverage: lines {total['line_pct']}% of {total['lines']}, branches {total['branch_pct']}% " + f"of {total['branches']} ({len(files)} kernel files, {len(exclusions)} excluded)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/model/campaign.py b/tools/model/campaign.py new file mode 100644 index 0000000..ff4726d --- /dev/null +++ b/tools/model/campaign.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Junior Deogracias +"""The M2-10 differential campaign: every catalogue scenario on each runner, for a range of +seeds, plus the exact timer regressions, summarized as JSON. + +Each (runner, seed) pair runs `bridge.py --runs N --seed S` as a separate process, so the +per-seed schedules are the same as a manual run. Failures are captured by the bridge as +usual (build/differential-failures/); the summary records counts and identities only. +""" +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import re +import subprocess +import sys + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +LINE = re.compile(r"bridge: (\d+)/(\d+) runs agree with the model, (\d+) scenarios skipped on this build, " + r"(\d+) divergences") +CONFIG = re.compile(r"bridge: runner (profile=\S+ owner_death=\S+ checked=\S+ tickless=\S+)") + + +def bridge(runner, *args, timeout): + cmd = [sys.executable, "-I", str(HERE / "bridge.py"), "--runner", str(runner), *map(str, args)] + out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + m = LINE.search(out.stdout) + if m is None: + raise RuntimeError(f"no bridge summary from {cmd}: {out.stdout[-500:]} {out.stderr[-500:]}") + agree, total, skipped, diverged = map(int, m.groups()) + c = CONFIG.search(out.stdout) + return dict(agree=agree, runs=total, skipped=skipped, divergences=diverged, + config=c.group(1) if c else None, exit=out.returncode) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--runner", action="append", required=True, type=Path, help="a diff_runner; repeatable") + ap.add_argument("--seeds", default="1-10", help="first-last (default 1-10)") + ap.add_argument("--runs", type=int, default=40) + ap.add_argument("--timeout", type=float, default=7200, help="per bridge process, seconds") + ap.add_argument("--out", type=Path, required=True, help="the summary JSON to write") + args = ap.parse_args(argv) + first, last = (int(x) for x in args.seeds.split("-")) + git = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip() + dirty = bool(subprocess.run(["git", "-C", str(ROOT), "status", "--porcelain", "--untracked-files=no"], + capture_output=True, text=True).stdout.strip()) + result = dict(schema="emblink-campaign", version=1, started=datetime.now(timezone.utc).isoformat(timespec="seconds"), + git_sha=git, tracked_dirty=dirty, runs_per_scenario=args.runs, seeds=[first, last], runners=[]) + for runner in args.runner: + entry = dict(runner=str(runner.relative_to(ROOT)) if runner.resolve().is_relative_to(ROOT) else str(runner), + runner_sha256=hashlib.sha256(runner.read_bytes()).hexdigest(), seeds={}) + for seed in range(first, last + 1): + entry["seeds"][seed] = bridge(runner, "--runs", args.runs, "--seed", seed, timeout=args.timeout) + print(f"campaign: {entry['runner']} seed {seed}: {entry['seeds'][seed]['agree']}/" + f"{entry['seeds'][seed]['runs']}", flush=True) + entry["regressions"] = bridge(runner, "--regressions", timeout=args.timeout) + entry["config"] = entry["regressions"]["config"] + entry["total"] = dict(runs=sum(s["runs"] for s in entry["seeds"].values()), + agree=sum(s["agree"] for s in entry["seeds"].values()), + divergences=sum(s["divergences"] for s in entry["seeds"].values())) + result["runners"].append(entry) + result["finished"] = datetime.now(timezone.utc).isoformat(timespec="seconds") + result["divergences"] = sum(r["total"]["divergences"] + r["regressions"]["divergences"] for r in result["runners"]) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(result, indent=2) + "\n") + print(f"campaign: {result['divergences']} divergences in all; summary {args.out}") + return 0 if result["divergences"] == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/model/tests/scenarios.py b/tools/model/tests/scenarios.py index b09d51d..81b08da 100644 --- a/tools/model/tests/scenarios.py +++ b/tools/model/tests/scenarios.py @@ -779,3 +779,73 @@ def dwork_isr_schedule(): DWORK = [dwork_runs_after_its_delay, dwork_reschedule_and_cancel, dwork_isr_schedule] ALL = ALL + DWORK + + +# --------------------------------------------------------------------------------------- +# M2-10: combined scenarios: several mechanisms meeting at section boundaries, with the ISR +# and tick placement left to the explorer and the bridge +# --------------------------------------------------------------------------------------- + + +def combined_suspend_cancel_msgq_receiver(): + """A receiver suspended, canceled and resumed while an ISR's send may hand it the item: + it receives the item, or is canceled and the item stays stored, never both or neither.""" + return Scenario(name="combined_suspend_cancel_msgq_receiver", + threads={1: (3, (MsgRecv(0, 6),)), 2: (2, (Suspend(1), Cancel(1), Resume(1)))}, + msgqs={0: dict(capacity=1)}, + isrs=((MsgSend(0, 5, NO_WAIT),),)) + + +def combined_inheritance_priority_change_event_timeout(): + """An inheriting owner whose base priority changes while a third thread's event wait races + an ISR set and its own timeout.""" + return Scenario(name="combined_inheritance_priority_change_event_timeout", + threads={1: (1, (Lock(0), Sleep(2), Unlock(0))), 2: (4, (Sleep(1), Lock(0, 3))), + 3: (2, (Sleep(1), SetPrio(1, 3), EventWait(0, 0x1, timeout=2)))}, + mutexes={0: {}}, events={0: dict(bits=0)}, + isrs=((EventSet(0, 0x1),),)) + + +def combined_destroy_timeout_and_isr_give(): + """A timed take whose timeout, another thread's give and the semaphore's destroy meet at the + same ticks. (A give after destroy is a stale handle: checked builds fault, so no ISR gives.)""" + return Scenario(name="combined_destroy_timeout_and_isr_give", + threads={1: (3, (Take(0, 2),)), 2: (1, (Sleep(2), Destroy(0))), 3: (2, (Sleep(1), Give(0)))}, + sems={0: dict(count=0)}) + + +def combined_bindings_two_sources(): + """One thread bound to a message queue and to event flags waits for either; the queue's + binding fires on empty to nonempty only, the event's for bits left set.""" + return Scenario(name="combined_bindings_two_sources", + threads={1: (3, (NotifyWait(0x3, timeout=5), MsgRecv(0, NO_WAIT), NotifyWait(0x3, timeout=3))), + 2: (1, (MsgSend(0, 1), EventSet(0, 0x1)))}, + msgqs={0: dict(capacity=2, binding=(1, 0))}, + events={0: dict(bits=0, binding=(1, 1))}) + + +def combined_timer_feeds_handler_queue(): + """An ISR-context timer sends into a message queue a work handler receives from, while the + thread flushes the work queue and stops the timer.""" + return Scenario(name="combined_timer_feeds_handler_queue", + threads={1: (1, (TimerStart(0, 1), Submit(0, 0), WorkFlush(0), TimerStop(0)))}, + msgqs={0: dict(capacity=1)}, + workqs={0: dict(tid=9, prio=2)}, + works={0: dict(body=(MsgRecv(0, 3),))}, + timers={0: dict(isr=True, body=(MsgSend(0, 7, NO_WAIT),))}) + + +def combined_dwork_cancel_races_expiry(): + """An ISR cancels a delayed item at any point around its expiry: it ran once or not at all.""" + return Scenario(name="combined_dwork_cancel_races_expiry", + threads={1: (1, (DworkSchedule(0, 0, 2), Take(0, 4)))}, + sems={0: dict(count=0)}, + workqs={0: dict(tid=9, prio=2)}, + works={0: dict(body=(Give(0),), delayed=True)}, + isrs=((DworkCancel(0),),)) + + +COMBINED = [combined_suspend_cancel_msgq_receiver, combined_inheritance_priority_change_event_timeout, + combined_destroy_timeout_and_isr_give, combined_bindings_two_sources, + combined_timer_feeds_handler_queue, combined_dwork_cancel_races_expiry] +ALL = ALL + COMBINED diff --git a/tools/model/tests/test_explore.py b/tools/model/tests/test_explore.py index 0074466..815eff0 100644 --- a/tools/model/tests/test_explore.py +++ b/tools/model/tests/test_explore.py @@ -610,3 +610,40 @@ def test_isr_schedule(self): for d, w in self.outcomes(S.dwork_isr_schedule): seen.add(d[1][0][1]) self.assertIn(Result.SATISFIED, seen) + + +class Combined(unittest.TestCase): + """M2-10: mechanisms combined at section boundaries. Every invariant of every group holds in + every explored state; these tests pin the end results that matter.""" + + def test_every_combined_scenario_holds_invariants(self): + for make in S.COMBINED: + sc = make() + with self.subTest(scenario=sc.name): + self.assertGreater(explore(sc).terminals, 0) + + def test_suspended_canceled_receiver_gets_the_item_or_leaves_it(self): + seen = set() + for o in explore(S.combined_suspend_cancel_msgq_receiver()).outcomes: + d, q = dict(dict(o)["threads"]), dict(dict(o)["msgqs"]) + r = d[1][0] + seen.add(r[1]) + if r[1] == Result.SATISFIED: + self.assertEqual((r[2], q[0]), (5, ())) + else: + self.assertIn(r[1], (Result.CANCELED, Result.TIMEOUT)) # the item, if sent, is stored + self.assertTrue({Result.SATISFIED, Result.CANCELED} <= seen) + + def test_destroy_timeout_and_give(self): + seen = {dict(dict(o)["threads"])[1][0][1] for o in explore(S.combined_destroy_timeout_and_isr_give()).outcomes} + self.assertTrue(seen <= {Result.SATISFIED, Result.TIMEOUT, Result.DESTROYED}) + self.assertIn(Result.SATISFIED, seen) + + def test_dwork_cancel_racing_expiry_runs_at_most_once(self): + seen = set() + for o in explore(S.combined_dwork_cancel_races_expiry()).outcomes: + d, w = dict(dict(o)["threads"]), {wid: n for wid, _, n in dict(o)["works"]} + seen.add(w[0]) + if w[0] == 0: + self.assertNotEqual(d[1][1][1], Result.SATISFIED) # canceled: nothing gave + self.assertEqual(seen, {0, 1}) From 77768fb7e3cadcd8a28dea060677b1dd59c16bf2 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Fri, 30 Oct 2026 22:57:58 +0100 Subject: [PATCH 23/30] fix(sched): raising a ready thread above the running one preempts at once embk_sched_prio_changed() set reschedule_pending only when the running thread's own effective priority fell below a ready thread. When another thread raised a ready thread above the caller with emb_thread_set_priority(), nothing marked the reschedule, and the caller kept running past its preemption point until its next block or yield. The M2-10 combined campaign found it: in release builds, where the scenario's owner-death policy lets the run continue, a raised owner did not run before the raising thread's next wait. The hook now marks the reschedule when a ready thread other than the running one rises above it. The new case sched_raising_a_ready_thread_above_the_caller_preempts fails on the old kernel (marks 1, 3, 2) and passes now. Signed-off-by: teo1747 --- kernel/sched/sched.c | 3 +++ tests/conformance/test_sched.c | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/kernel/sched/sched.c b/kernel/sched/sched.c index 6972922..2d366c0 100644 --- a/kernel/sched/sched.c +++ b/kernel/sched/sched.c @@ -343,5 +343,8 @@ void embk_sched_prio_changed(embk_thread_t *t, uint8_t old_eff) if (top != NULL && top->eff_prio > t->eff_prio) { embk_cpu.reschedule_pending = 1u; } + } else if (embk_cpu.current != NULL && t->eff_prio > embk_cpu.current->eff_prio && + embk_sched_is_ready(t)) { + embk_cpu.reschedule_pending = 1u; /* a ready thread raised above the running one */ } } diff --git a/tests/conformance/test_sched.c b/tests/conformance/test_sched.c index e2d731a..7733db7 100644 --- a/tests/conformance/test_sched.c +++ b/tests/conformance/test_sched.c @@ -126,3 +126,27 @@ EMB_TEST(sched_thread_self_and_priorities) EMB_ASSERT_EQ(emb_thread_get_effective_priority(me), EMB_TEST_RUNNER_PRIORITY); EMB_ASSERT_EQ(emb_context(), EMB_CONTEXT_THREAD); } + +static emb_thread_t raised; + +static void raised_body(void *arg) +{ + (void)arg; + emb_test_mark(2); +} + +static void raiser(void *arg) +{ + (void)arg; + raised = emb_test_thread(EMB_TEST_NAME("lo"), 2u, 0u, raised_body, NULL); /* ready, below us */ + emb_test_mark(1); + EMB_ASSERT_OK(emb_thread_set_priority(raised, 4u)); /* now above us: it runs at once */ + emb_test_mark(3); +} + +EMB_TEST(sched_raising_a_ready_thread_above_the_caller_preempts) +{ + EMB_TEST_REQ("KRN-THR-022", "KRN-SYNC-032"); + (void)emb_test_thread(EMB_TEST_NAME("hi"), 3u, 0u, raiser, NULL); + EMB_ASSERT_MARKS(1, 2, 3); +} From 617e1dbce89f77d78b6b09714063a3302e94d09c Mon Sep 17 00:00:00 2001 From: teo1747 Date: Fri, 30 Oct 2026 22:57:58 +0100 Subject: [PATCH 24/30] fix(native): keep registration tables dense under Mach-O AddressSanitizer Clang's AddressSanitizer on Mach-O pads globals in custom sections with redzones, so walking the test or init table read the padding as entries. Every conformance suite aborted with a global-buffer-overflow in the runner, and native-asan could not run on macOS. ELF builds leave section globals alone, which is why CI passed. Table entries now carry EMB_TABLE_ENTRY_ATTR: no_sanitize("address") under Clang with AddressSanitizer, empty everywhere else. native-asan passes 20/20 locally and the tiny ASan build 17/17. Signed-off-by: teo1747 --- include/emb/compiler.h | 4 ++++ include/emb/compiler/clang.h | 25 ++++++++++++++++--------- include/emb/kernel.h | 6 +++--- tests/framework/emb_test.h | 10 +++++----- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/include/emb/compiler.h b/include/emb/compiler.h index 1573066..6effd2f 100644 --- a/include/emb/compiler.h +++ b/include/emb/compiler.h @@ -144,6 +144,10 @@ extern "C" { /* A registration-table entry: placed by its section name alone, never by a * program-memory attribute (which overrides the section on GCC for AVR). */ #define EMB_TABLE_CONST const +/* What a table entry needs so that its section stays a dense array (sanitizers). */ +#ifndef EMB_TABLE_ENTRY_ATTR +#define EMB_TABLE_ENTRY_ATTR +#endif /* The bounds of a bracketed registration table (09 §7): declare once per file with * EMB_TABLE_BOUNDS, read with EMB_TABLE_START and EMB_TABLE_STOP. The fallback is the diff --git a/include/emb/compiler/clang.h b/include/emb/compiler/clang.h index ae6aabd..d8dc5ff 100644 --- a/include/emb/compiler/clang.h +++ b/include/emb/compiler/clang.h @@ -13,15 +13,22 @@ ((__clang_major__ * 10000) + (__clang_minor__ * 100) + __clang_patchlevel__) /* NOLINTBEGIN(portability-no-assembler,hicpp-no-assembler): CS-1.5, the compiler layer */ -#define EMB_INLINE inline -#define EMB_ALWAYS_INLINE inline __attribute__((always_inline)) -#define EMB_NOINLINE __attribute__((noinline)) -#define EMB_NORETURN __attribute__((noreturn)) -#define EMB_UNUSED __attribute__((unused)) -#define EMB_USED __attribute__((used)) -#define EMB_WEAK __attribute__((weak)) -#define EMB_ALIAS(sym) __attribute__((alias(#sym))) -#define EMB_SECTION(name) __attribute__((section(name))) +#define EMB_INLINE inline +#define EMB_ALWAYS_INLINE inline __attribute__((always_inline)) +#define EMB_NOINLINE __attribute__((noinline)) +#define EMB_NORETURN __attribute__((noreturn)) +#define EMB_UNUSED __attribute__((unused)) +#define EMB_USED __attribute__((used)) +#define EMB_WEAK __attribute__((weak)) +#define EMB_ALIAS(sym) __attribute__((alias(#sym))) +#define EMB_SECTION(name) __attribute__((section(name))) +#if defined(__has_feature) +#if __has_feature(address_sanitizer) +/* Mach-O AddressSanitizer pads section globals with redzones, which a table walk would + * read as entries; ELF builds leave section globals alone. */ +#define EMB_TABLE_ENTRY_ATTR __attribute__((no_sanitize("address"))) +#endif +#endif #define EMB_ALIGNED(n) __attribute__((aligned(n))) #define EMB_PACKED __attribute__((packed)) #define EMB_NAKED __attribute__((naked)) diff --git a/include/emb/kernel.h b/include/emb/kernel.h index 0a1442a..c110706 100644 --- a/include/emb/kernel.h +++ b/include/emb/kernel.h @@ -62,9 +62,9 @@ typedef struct EMB_ALIGNED(sizeof(void *)) emb_init_entry { /* Registers @fn to run from emb_kernel_init(). The table is the bracketed section * emb_init_table that embld and GNU ld both provide (09 §7). MISRA Dev CS-12: rule * 20.7, `fn` is a token. */ -#define EMB_INIT_TABLE_ENTRY(fn) \ - static EMB_TABLE_CONST emb_init_entry_t fn##_init_entry_ EMB_USED EMB_SECTION( \ - EMB_TABLE_SECTION("emb_init_table")) = {fn} +#define EMB_INIT_TABLE_ENTRY(fn) \ + static EMB_TABLE_CONST emb_init_entry_t fn##_init_entry_ EMB_USED EMB_TABLE_ENTRY_ATTR \ + EMB_SECTION(EMB_TABLE_SECTION("emb_init_table")) = {fn} /* ---- EMB_CHECK --------------------------------------------------------------- */ diff --git a/tests/framework/emb_test.h b/tests/framework/emb_test.h index 90a272f..7558856 100644 --- a/tests/framework/emb_test.h +++ b/tests/framework/emb_test.h @@ -31,11 +31,11 @@ typedef struct EMB_ALIGNED(sizeof(void *) * 4) emb_test_case { } emb_test_case_t; /* Defines a test and registers it. MISRA Dev CS-12: rule 20.7, `name` is a token. */ -#define EMB_TEST(name) \ - static void name(void); \ - static EMB_FLASH_CONST char name##_name_[] = #name; \ - static EMB_TABLE_CONST emb_test_case_t name##_case_ \ - EMB_USED EMB_SECTION(EMB_TABLE_SECTION("emb_test_table")) = {name##_name_, name, ""}; \ +#define EMB_TEST(name) \ + static void name(void); \ + static EMB_FLASH_CONST char name##_name_[] = #name; \ + static EMB_TABLE_CONST emb_test_case_t name##_case_ EMB_USED EMB_TABLE_ENTRY_ATTR EMB_SECTION( \ + EMB_TABLE_SECTION("emb_test_table")) = {name##_name_, name, ""}; \ static void name(void) /* Requirement citation inside a test body: printed as the case's "req" line. */ From b04cf96f16aad314a429c3637a4cd8ad1f8f5e66 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Fri, 30 Oct 2026 22:57:58 +0100 Subject: [PATCH 25/30] fix(model): resolve campaign runner paths before naming them Signed-off-by: teo1747 --- tools/model/campaign.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/model/campaign.py b/tools/model/campaign.py index ff4726d..3731e8a 100644 --- a/tools/model/campaign.py +++ b/tools/model/campaign.py @@ -50,8 +50,8 @@ def main(argv=None): capture_output=True, text=True).stdout.strip()) result = dict(schema="emblink-campaign", version=1, started=datetime.now(timezone.utc).isoformat(timespec="seconds"), git_sha=git, tracked_dirty=dirty, runs_per_scenario=args.runs, seeds=[first, last], runners=[]) - for runner in args.runner: - entry = dict(runner=str(runner.relative_to(ROOT)) if runner.resolve().is_relative_to(ROOT) else str(runner), + for runner in (r.resolve() for r in args.runner): + entry = dict(runner=str(runner.relative_to(ROOT)) if runner.is_relative_to(ROOT) else str(runner), runner_sha256=hashlib.sha256(runner.read_bytes()).hexdigest(), seeds={}) for seed in range(first, last + 1): entry["seeds"][seed] = bridge(runner, "--runs", args.runs, "--seed", seed, timeout=args.timeout) From f563ef5ca99cda5898bfcfb328d5e84afc8df257 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Fri, 30 Oct 2026 23:07:47 +0100 Subject: [PATCH 26/30] fix(thread): preempt after a priority raise without mutexes too The previous fix marked the reschedule in the scheduler's priority hook, which the inheritance recomputation calls. A build without mutexes, such as the tiny footprint configuration, sets the effective priority directly and never reaches that hook, so sched_raising_a_ready_thread_above_the_caller_preempts still failed on avr-uno-t1. emb_thread_set_priority() now checks the raised thread against the running one itself. Signed-off-by: teo1747 --- kernel/thread/thread.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/kernel/thread/thread.c b/kernel/thread/thread.c index a21be9e..174a913 100644 --- a/kernel/thread/thread.c +++ b/kernel/thread/thread.c @@ -625,6 +625,10 @@ emb_status_t emb_thread_set_priority(emb_thread_t thread, uint8_t priority) t->base_prio = priority; #endif embk_prio_propagate(t); + if (t != embk_cpu.current && embk_sched_is_ready(t) && + t->eff_prio > embk_cpu.current->eff_prio) { + embk_cpu.reschedule_pending = 1u; /* also where no recomputation runs (tiny, no mutexes) */ + } EMBK_TRACE(EMB_TRACE_PRIORITY, embk_thread_index(t), t->base_prio, t->eff_prio); embk_sched_reschedule_if_needed(); embk_unlock_sched(key); From 2a29b1b120d652a772469428194a935e9e71a376 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Fri, 30 Oct 2026 23:18:12 +0100 Subject: [PATCH 27/30] docs(m2-10): integrated campaign evidence and report - tools/model/evidence/m2-10/: the campaign summaries for native gcc, clang, periodic, release and tiny (seeds 1-10, --runs 40, plus the timer regressions): 158,800 runs, no divergence, clean tree at f563ef5. Also the kernel coverage (91.4% of lines, 72.5% of branches, nothing excluded) and the requirement-matrix summary over all 21 configurations. - docs/engineering/m2-10-campaign.md reports the combined scenarios, the two defects the campaign found (the missing preemption on a priority raise and the macOS AddressSanitizer table padding), the sanitizer results, the lowest-covered files, and the implemented requirements verified by analysis rather than by a case. - CI builds native-coverage, publishes the report and runs the report tool's tests. The sanitizer and coverage configurations join the evidence catalogue. - msgq_small_counts_refuse_a_capacity_above_255 gives KRN-IPC-021 an executed case. - The checklist's two campaign items, STATUS and the changelog are updated. Signed-off-by: teo1747 --- .github/workflows/ci.yml | 13 +- CHANGELOG.md | 6 + docs/STATUS.md | 2 + docs/engineering/index.md | 1 + docs/engineering/m2-10-campaign.md | 76 +++++++ docs/engineering/m2-checklist.md | 4 +- tests/conformance/test_msgq.c | 16 ++ tools/coverage/exclusions.json | 5 +- tools/coverage/test_report.py | 39 ++++ tools/evidence/configurations.json | 3 + tools/model/evidence/m2-10/README.md | 7 + .../evidence/m2-10/campaign-native-clang.json | 116 ++++++++++ .../evidence/m2-10/campaign-native-gcc.json | 116 ++++++++++ .../m2-10/campaign-native-periodic.json | 116 ++++++++++ .../m2-10/campaign-native-release.json | 116 ++++++++++ .../evidence/m2-10/campaign-native-tiny.json | 116 ++++++++++ tools/model/evidence/m2-10/coverage.json | 139 ++++++++++++ .../model/evidence/m2-10/matrix-summary.json | 211 ++++++++++++++++++ 18 files changed, 1098 insertions(+), 4 deletions(-) create mode 100644 docs/engineering/m2-10-campaign.md create mode 100644 tools/coverage/test_report.py create mode 100644 tools/model/evidence/m2-10/README.md create mode 100644 tools/model/evidence/m2-10/campaign-native-clang.json create mode 100644 tools/model/evidence/m2-10/campaign-native-gcc.json create mode 100644 tools/model/evidence/m2-10/campaign-native-periodic.json create mode 100644 tools/model/evidence/m2-10/campaign-native-release.json create mode 100644 tools/model/evidence/m2-10/campaign-native-tiny.json create mode 100644 tools/model/evidence/m2-10/coverage.json create mode 100644 tools/model/evidence/m2-10/matrix-summary.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38c2c1c..8ee2b9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: strategy: fail-fast: false matrix: - preset: [native-gcc, native-clang, native-release, native-tiny, native-periodic, native-asan, native-tsan] + preset: [native-gcc, native-clang, native-release, native-tiny, native-periodic, native-asan, native-tsan, native-coverage] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install tools @@ -39,6 +39,15 @@ jobs: run: cmake --build --preset ${{ matrix.preset }} - name: Test run: ctest --preset ${{ matrix.preset }} --output-on-failure --no-tests=error + - name: Coverage report + if: matrix.preset == 'native-coverage' + run: python3 -I tools/coverage/report.py --json coverage.json --markdown coverage.md && cat coverage.md >> "$GITHUB_STEP_SUMMARY" + - name: Upload coverage + if: matrix.preset == 'native-coverage' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: coverage + path: coverage.* - name: Collect case evidence if: always() run: python3 -I tools/evidence/collect.py --build build/${{ matrix.preset }} --preset ${{ matrix.preset }} --out case-evidence-${{ matrix.preset }}.json @@ -228,6 +237,8 @@ jobs: run: python3 -I -m unittest discover -s tools/footprint -p 'test_*.py' -v - name: Case evidence tools run: python3 -I -m unittest discover -s tools/evidence -p 'test_*.py' -v + - name: Coverage report tool tests + run: python3 -I -m unittest discover -s tools/coverage -p 'test_*.py' -v - name: Resource-bound inventory covers every non-constant cost run: python3 -I tools/evidence/check_bounds.py - name: Configuration tool self-check diff --git a/CHANGELOG.md b/CHANGELOG.md index b65cee1..f08e87f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Added +- The M2-10 integrated campaign: combined model scenarios across primitives, a `native-coverage` preset with `tools/coverage/report.py` (statement and branch coverage, run in CI), and `tools/model/campaign.py`, whose 158,800-run summaries are archived under `tools/model/evidence/m2-10/` (see `docs/engineering/m2-10-campaign.md`). + - Delayed work (`emb_dwork_t`, M2-09, KRN-WQ-003/007): an item that an internal interrupt-context timer submits to a work queue after a delay or at an instant, with idempotent schedule, reschedule, cancel reporting a stopped delay or removed item as pending, and `cancel_sync`. The queue cannot be destroyed while a delay names it. The reference model, the differential bridge and the work queue conformance suite cover it. - Software timers (`CONFIG_EMB_TIMER`, `include/emb/timer.h`, M2-09): one-shot and periodic timers whose callbacks run on a named work queue or, with `EMB_TIMER_ISR_CONTEXT`, in the expiry interrupt. Periodic timers re-arm from the previous deadline, skip missed periods in closed form and count them with coalesced callbacks as overruns; `stop` reports the callback's state and `stop_sync` waits for it. Timers have their own deadline list, merged with thread timeouts at expiry (ADR-047). The reference model, the differential bridge and a conformance suite cover them; they require the list scheduler, so the tiny profile has none in this milestone. @@ -73,6 +75,10 @@ User-visible changes are recorded here. There are no released versions or releas ### Fixed +- Raising a ready thread's priority above the running thread with `emb_thread_set_priority()` now preempts at once; before, the caller ran on until its next block or yield (found by the M2-10 campaign). + +- The conformance suites run under AddressSanitizer on macOS: registration-table entries no longer get Mach-O redzones that the table walk read as entries. + - Thread diagnostic IDs remain stable across priority changes on tiny and unique among live objects on every profile. IDs are released only by successful destroy; exhaustion returns `EMB_ENOMEM` without partial construction. - Generic IRQ priorities now increase with urgency on Cortex-M, with board-derived bounds and validation before register writes; fixed-level native/AVR/RV32 reject nonzero levels. SysTick defaults to highest kernel-aware urgency. Raw kernel-independent vectors remain unsupported. diff --git a/docs/STATUS.md b/docs/STATUS.md index 8260224..745060a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -32,6 +32,8 @@ An API or path named in a design or a requirement's verification field may be a ## Validation recorded in this workspace +The [M2-10 integrated campaign](engineering/m2-10-campaign.md), source `f563ef5`, passed 158,800 differential runs across seeds 1–10 on native checked/clang/periodic/release/tiny with no divergence; all 21 local configurations (native, sanitizers, coverage, QEMU, AVR, EmbSIM) passed; kernel coverage was 91.4% of lines and 72.5% of branches. Hardware-in-the-loop and physical timing remain unrun. + The onboarding runs on the baseline used GCC 14.2.0, Python 3.12.14, CMake 3.31.10 and Ninja 1.13.0 on Linux. `native-gcc`, `native-release`, and `native-tiny` each passed ten CTest targets, including nine conformance executables and the differential bridge. Those are runner targets, not ten individual assertions. The base checked run reported 82 internal conformance cases with zero skips, and 210/210 bridge runs agreed with the model with one scenario excluded for that configuration. Counts for other configurations must be read from their own logs. The reference model passed 44 pytest tests with Hypothesis installed. The banned-construct check reported zero findings. Configuration generation for native/base passed. A repeat setup run reused build outputs and passed. These are current-workspace observations, not archived release artifacts or evidence of a new machine, compiler matrix, physical board, or published environment. diff --git a/docs/engineering/index.md b/docs/engineering/index.md index 78f32ce..ed43fb8 100644 --- a/docs/engineering/index.md +++ b/docs/engineering/index.md @@ -18,6 +18,7 @@ m2-02-irq-priority m2-02-thread-identity m2-02-fault-api-ids m2-02-object-lifecycle +m2-10-campaign case-evidence resource-bounds embcc-capability-review diff --git a/docs/engineering/m2-10-campaign.md b/docs/engineering/m2-10-campaign.md new file mode 100644 index 0000000..3217dcc --- /dev/null +++ b/docs/engineering/m2-10-campaign.md @@ -0,0 +1,76 @@ +# M2-10 integrated campaign + +**Date:** 2026-10-30. **Kernel at:** `f563ef5` (campaign) and `f563ef5` plus this report (coverage, matrix). **Scope:** every M2 primitive on every native configuration, the emulated targets, sanitizers and coverage. Hardware-in-the-loop and physical timing remain unrun: no NUCLEO-F446RE board was available. + +## Combined scenarios + +The `COMBINED` group of the reference model (`tools/model/tests/scenarios.py`) meets several mechanisms at section boundaries. The model explores every ISR and tick placement; the differential bridge replays seeded placements on the kernel: + +| Scenario | What meets | +|---|---| +| `combined_suspend_cancel_msgq_receiver` | a suspended, canceled and resumed message queue receiver against an ISR send (capacity 1) | +| `combined_inheritance_priority_change_event_timeout` | an inheriting owner's base-priority change against another thread's event wait, its timeout and an ISR set | +| `combined_destroy_timeout_and_isr_give` | a timed take against a give and the semaphore's destroy at the same ticks | +| `combined_bindings_two_sources` | one thread bound to a message queue and to event flags | +| `combined_timer_feeds_handler_queue` | an ISR-context timer feeding a queue that a work handler reads, while the thread flushes the work queue and stops the timer | +| `combined_dwork_cancel_races_expiry` | an ISR cancel racing a delayed item's expiry | + +Every invariant of every group holds in every explored state (122 model tests, `cd tools/model && python3 -m pytest`). + +## Differential campaign + +`tools/model/campaign.py` ran the whole catalogue with `--runs 40` for seeds 1 to 10, plus the exact timer regressions, on five native runners (summaries in `tools/model/evidence/m2-10/campaign-*.json`, clean tree at `f563ef5`): + +| Runner | Configuration | Runs | Agree | Regressions | +|---|---|---:|---:|---:| +| native-gcc | base, checked, tickless | 33,600 | 33,600 | 5/5 | +| native-clang | base, checked, tickless | 33,600 | 33,600 | 5/5 | +| native-periodic | base, checked, periodic | 33,600 | 33,600 | 5/5 | +| native-release | base, release, owner death releases | 33,600 | 33,600 | 5/5 | +| native-tiny | tiny, checked (no work queues, timers or condition variables) | 24,400 | 24,400 | 4/4 | + +158,800 runs, no divergence. Mutation checks during M2-07 to M2-09 confirmed that the bridge catches deliberately broken message queues, work queues, timers and delayed work. + +## Defects the campaign found + +- **A raised ready thread did not preempt.** `emb_thread_set_priority()` raising a ready thread above the caller left the caller running until its next block or yield: the scheduler hook marked a reschedule only when the running thread's own priority fell. The combined inheritance scenario diverged in release builds, where its owner-death policy lets the run continue. Fixed in the hook and, for builds without mutexes, in `emb_thread_set_priority()`; `sched_raising_a_ready_thread_above_the_caller_preempts` fails on the old kernel and passes on every target now. +- **AddressSanitizer could not run the suites on macOS.** Clang pads Mach-O section globals with redzones, so the test and init table walks read padding as entries. Table entries now opt out (`EMB_TABLE_ENTRY_ATTR`); `native-asan` passes 20/20 locally, as it already did on CI's Linux runners. + +Earlier M2 campaigns found the work queue flush deadline (M2-08, now a timer regression) and the RISC-V reset-section collision (M2-09). + +## Sanitizers + +| Build | Result | +|---|---| +| native-asan (AddressSanitizer + UBSan) | 20/20 | +| native-tsan (ThreadSanitizer) | 20/20 | +| tiny AddressSanitizer build | 17/17 | + +## Coverage + +`native-coverage` (GCC `--coverage`, `-O0`, checked) after its full CTest run, which includes the differential bridge, measured with `tools/coverage/report.py` (CI runs the same; `tools/model/evidence/m2-10/coverage.json`): **91.4%** of 2,900 kernel lines and **72.5%** of 1,472 branches taken, over all 18 kernel files of the base build; nothing is excluded. Header inline functions are counted in the files that include them. The lowest files and why: + +| File | Lines | Branches | Not reached | +|---|---:|---:|---| +| `kernel/irq/irq.c` | 21/32 | 13/16 | connect and priority paths the native port does not use | +| `kernel/exec/exec.c` | 87/108 | 32/50 | early-start failure and port-specific paths | +| `kernel/work/dwork.c` | 79/89 | 29/48 | misuse arguments and the cancel-between-expiry-and-submit window | +| `kernel/sync/mutex.c` | 242/278 | 113/152 | depth-limit and rare ceiling/recursion branches | + +Misuse faults are covered where checked builds fork to observe them; release-build misuse returns are covered by the release presets' suites, which this coverage build does not measure. + +## Case evidence and gaps + +The requirement matrix (`tools/evidence/matrix.py` over the case evidence of all 21 configurations, `tools/model/evidence/m2-10/matrix-summary.json`) has no failed case. Of 377 catalogued requirements, 127 have a passing executed case; 39 are marked implemented in their cards. The implemented ones without an executed case are verified by analysis or by the model, as their cards say: + +- KRN-IPC-022 (model coverage before the queue implementation): analysis. +- KRN-WQ-011 (work queue trace and statistics): trace records exist, statistics do not; no trace decoder test. + +KRN-IPC-021 gained `msgq_small_counts_refuse_a_capacity_above_255` in this campaign. Fourteen cited KRN-SCH and KRN-MEM IDs still belong to requirement groups pending migration into the catalogue. + +## Not covered + +- Hardware-in-the-loop, physical timing, masked-time and jitter measurements (no board). +- Work queues, software timers, delayed work and condition variables on the tiny profile, which has none in M2. +- The EmbCC native flow, which CI does not build. +- Seed-5 of M2-01 stays closed as not reproducible. diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index 5c12bf3..3c72d67 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -85,8 +85,8 @@ For each new public API: complete annotations and parameter/lifetime docs, gener ## M2-10 and M2-11: Integrated exit -- [ ] Combine timeout, cancel, suspend, destroy, hand-off, priority change and callback delivery at section boundaries, with boundary resource counts and adversarial IRQ/tick placement. -- [ ] Execute applicable misuse/property/sanitizer campaigns and report statement/branch coverage with exclusions and actual case identity. +- [x] Combine timeout, cancel, suspend, destroy, hand-off, priority change and callback delivery at section boundaries, with boundary resource counts and adversarial IRQ/tick placement. +- [x] Execute applicable misuse/property/sanitizer campaigns and report statement/branch coverage with exclusions and actual case identity. See the [M2-10 campaign report](m2-10-campaign.md). - [ ] Reconcile every delivered API's docs, configuration, model, current complexity and implemented requirement scope. - [ ] Require green current CI plus the scoped individual evidence; no unexplained required failure and no unsupported feature advertised as working. - [ ] Update STATUS, Unreleased changelog and tutorials, then record the M2 exit review and remaining M3a hardware/release work. diff --git a/tests/conformance/test_msgq.c b/tests/conformance/test_msgq.c index d0b765b..3de770a 100644 --- a/tests/conformance/test_msgq.c +++ b/tests/conformance/test_msgq.c @@ -294,3 +294,19 @@ EMB_TEST(msgq_storage_reuse_requires_destroy) EMB_ASSERT_OK(emb_msgq_destroy(mq)); } #endif + +#if CONFIG_EMB_IPC_SMALL +EMB_TEST(msgq_small_counts_refuse_a_capacity_above_255) +{ + static EMB_MSGQ_RING(big_ring, 1u, 1u); + emb_msgq_attr_t attr; + emb_msgq_t q; + EMB_TEST_REQ("KRN-IPC-021"); + emb_msgq_attr_default(&attr); + attr.ring = big_ring; + attr.ring_size = 256u; /* claimed: the capacity check comes first */ + attr.item_size = 1u; + attr.capacity = 256u; + EMB_ASSERT_MISUSE(emb_msgq_init(&storage, &attr, &q), EMB_EINVAL); /* 8-bit counts (A45) */ +} +#endif diff --git a/tools/coverage/exclusions.json b/tools/coverage/exclusions.json index 69b685d..6af1ca1 100644 --- a/tools/coverage/exclusions.json +++ b/tools/coverage/exclusions.json @@ -1 +1,4 @@ -{"files": {}} +{ + "_comment": "Kernel files left out of the coverage totals, with the reason (M2-10). Only kernel .c files of the native-coverage build count; files of other profiles (the tiny slim idle context) are not built there. Nothing is excluded: every built kernel file counts.", + "files": {} +} diff --git a/tools/coverage/test_report.py b/tools/coverage/test_report.py new file mode 100644 index 0000000..ff29731 --- /dev/null +++ b/tools/coverage/test_report.py @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Junior Deogracias +"""Unit tests for the coverage report's gcov parsing and totals.""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import report # noqa: E402 + +SAMPLE = """File '/x/kernel/sched/sched.c' +Lines executed:50.00% of 10 +Branches executed:100.00% of 4 +Taken at least once:75.00% of 4 +No calls + +File '/x/kernel/include/embk/list.h' +Lines executed:100.00% of 3 +No branches +No calls +""" + + +class Report(unittest.TestCase): + def test_gcov_blocks_parse_lines_and_branches(self): + blocks = {m.group(1): m.groups()[1:] for m in report.BLOCK.finditer(SAMPLE)} + self.assertEqual(blocks["/x/kernel/sched/sched.c"], ("50.00", "10", "75.00", "4")) + self.assertEqual(blocks["/x/kernel/include/embk/list.h"][:2], ("100.00", "3")) + + def test_exclusions_leave_the_totals(self): + files = {"kernel/a.c": dict(lines=10, lines_hit=5, branches=4, branches_taken=3, executed=True), + "kernel/b.c": dict(lines=10, lines_hit=0, branches=0, branches_taken=0, executed=False)} + md, total = report.render(files, {"kernel/b.c": "tiny profile only"}, "t") + self.assertEqual((total["lines"], total["line_pct"], total["branch_pct"]), (10, 50.0, 75.0)) + self.assertIn("excluded: tiny profile only", md) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/evidence/configurations.json b/tools/evidence/configurations.json index 31f881c..9492e4e 100644 --- a/tools/evidence/configurations.json +++ b/tools/evidence/configurations.json @@ -6,6 +6,9 @@ "native-release": {"target": "native", "scope": "host", "profile": "base", "checked": false}, "native-tiny": {"target": "native", "scope": "host", "profile": "tiny", "checked": true}, "native-periodic": {"target": "native", "scope": "host", "profile": "base", "checked": true}, + "native-asan": {"target": "native", "scope": "host", "profile": "base", "checked": true}, + "native-tsan": {"target": "native", "scope": "host", "profile": "base", "checked": true}, + "native-coverage": {"target": "native", "scope": "host", "profile": "base", "checked": true}, "cm3-an385": {"target": "qemu mps2-an385", "scope": "emulated", "profile": "base", "checked": true}, "cm4-an386": {"target": "qemu mps2-an386", "scope": "emulated", "profile": "base", "checked": true}, "cm4-an386-release": {"target": "qemu mps2-an386", "scope": "emulated", "profile": "base", "checked": false}, diff --git a/tools/model/evidence/m2-10/README.md b/tools/model/evidence/m2-10/README.md new file mode 100644 index 0000000..32d4e8c --- /dev/null +++ b/tools/model/evidence/m2-10/README.md @@ -0,0 +1,7 @@ +# M2-10 campaign evidence + +- `campaign-.json`: `tools/model/campaign.py` summaries (seeds 1 to 10, `--runs 40`, plus the timer regressions) with the commit, the tree's cleanliness and each runner's SHA-256. +- `coverage.json`: `tools/coverage/report.py` over the `native-coverage` build after its CTest run. +- `matrix-summary.json`: the per-configuration summary of `tools/evidence/matrix.py` over all 21 configurations' case evidence, with the implemented requirements that have no executed case. + +The report is `docs/engineering/m2-10-campaign.md`. diff --git a/tools/model/evidence/m2-10/campaign-native-clang.json b/tools/model/evidence/m2-10/campaign-native-clang.json new file mode 100644 index 0000000..b35c83a --- /dev/null +++ b/tools/model/evidence/m2-10/campaign-native-clang.json @@ -0,0 +1,116 @@ +{ + "schema": "emblink-campaign", + "version": 1, + "started": "2026-10-30T22:14:22+00:00", + "git_sha": "f563ef5ca99cda5898bfcfb328d5e84afc8df257", + "tracked_dirty": false, + "runs_per_scenario": 40, + "seeds": [ + 1, + 10 + ], + "runners": [ + { + "runner": "build/native-clang/tests/differential/diff_runner", + "runner_sha256": "ee53d5980b9ba4af4f00ff013d0030f96b59ee7e4a355283fcde950d70dbd66a", + "seeds": { + "1": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "2": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "3": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "4": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "5": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "6": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "7": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "8": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "9": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "10": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + } + }, + "regressions": { + "agree": 5, + "runs": 5, + "skipped": 0, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "total": { + "runs": 33600, + "agree": 33600, + "divergences": 0 + } + } + ], + "finished": "2026-10-30T22:16:02+00:00", + "divergences": 0 +} diff --git a/tools/model/evidence/m2-10/campaign-native-gcc.json b/tools/model/evidence/m2-10/campaign-native-gcc.json new file mode 100644 index 0000000..ef71ed7 --- /dev/null +++ b/tools/model/evidence/m2-10/campaign-native-gcc.json @@ -0,0 +1,116 @@ +{ + "schema": "emblink-campaign", + "version": 1, + "started": "2026-10-30T22:14:22+00:00", + "git_sha": "f563ef5ca99cda5898bfcfb328d5e84afc8df257", + "tracked_dirty": false, + "runs_per_scenario": 40, + "seeds": [ + 1, + 10 + ], + "runners": [ + { + "runner": "build/native-gcc/tests/differential/diff_runner", + "runner_sha256": "77dc9f79dce2b6d18a7be3a4bc9c3e2d14e649adf3ebb21d49da37a2ff8039d3", + "seeds": { + "1": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "2": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "3": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "4": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "5": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "6": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "7": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "8": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "9": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "10": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + } + }, + "regressions": { + "agree": 5, + "runs": 5, + "skipped": 0, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "config": "profile=base owner_death=fault checked=1 tickless=1;", + "total": { + "runs": 33600, + "agree": 33600, + "divergences": 0 + } + } + ], + "finished": "2026-10-30T22:16:02+00:00", + "divergences": 0 +} diff --git a/tools/model/evidence/m2-10/campaign-native-periodic.json b/tools/model/evidence/m2-10/campaign-native-periodic.json new file mode 100644 index 0000000..f27b646 --- /dev/null +++ b/tools/model/evidence/m2-10/campaign-native-periodic.json @@ -0,0 +1,116 @@ +{ + "schema": "emblink-campaign", + "version": 1, + "started": "2026-10-30T22:14:22+00:00", + "git_sha": "f563ef5ca99cda5898bfcfb328d5e84afc8df257", + "tracked_dirty": false, + "runs_per_scenario": 40, + "seeds": [ + 1, + 10 + ], + "runners": [ + { + "runner": "build/native-periodic/tests/differential/diff_runner", + "runner_sha256": "38ea41d27a795fb389e6d72c6c9185e80204c8a196f6a6d92b11991b53a8e09f", + "seeds": { + "1": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=0;", + "exit": 0 + }, + "2": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=0;", + "exit": 0 + }, + "3": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=0;", + "exit": 0 + }, + "4": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=0;", + "exit": 0 + }, + "5": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=0;", + "exit": 0 + }, + "6": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=0;", + "exit": 0 + }, + "7": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=0;", + "exit": 0 + }, + "8": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=0;", + "exit": 0 + }, + "9": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=0;", + "exit": 0 + }, + "10": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=0;", + "exit": 0 + } + }, + "regressions": { + "agree": 5, + "runs": 5, + "skipped": 0, + "divergences": 0, + "config": "profile=base owner_death=fault checked=1 tickless=0;", + "exit": 0 + }, + "config": "profile=base owner_death=fault checked=1 tickless=0;", + "total": { + "runs": 33600, + "agree": 33600, + "divergences": 0 + } + } + ], + "finished": "2026-10-30T22:16:02+00:00", + "divergences": 0 +} diff --git a/tools/model/evidence/m2-10/campaign-native-release.json b/tools/model/evidence/m2-10/campaign-native-release.json new file mode 100644 index 0000000..b1e875a --- /dev/null +++ b/tools/model/evidence/m2-10/campaign-native-release.json @@ -0,0 +1,116 @@ +{ + "schema": "emblink-campaign", + "version": 1, + "started": "2026-10-30T22:14:22+00:00", + "git_sha": "f563ef5ca99cda5898bfcfb328d5e84afc8df257", + "tracked_dirty": false, + "runs_per_scenario": 40, + "seeds": [ + 1, + 10 + ], + "runners": [ + { + "runner": "build/native-release/tests/differential/diff_runner", + "runner_sha256": "7bb008ceccb4c3d81b41499222cd065755ee103142830156c533117360a3fd60", + "seeds": { + "1": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=release checked=0 tickless=1;", + "exit": 0 + }, + "2": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=release checked=0 tickless=1;", + "exit": 0 + }, + "3": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=release checked=0 tickless=1;", + "exit": 0 + }, + "4": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=release checked=0 tickless=1;", + "exit": 0 + }, + "5": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=release checked=0 tickless=1;", + "exit": 0 + }, + "6": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=release checked=0 tickless=1;", + "exit": 0 + }, + "7": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=release checked=0 tickless=1;", + "exit": 0 + }, + "8": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=release checked=0 tickless=1;", + "exit": 0 + }, + "9": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=release checked=0 tickless=1;", + "exit": 0 + }, + "10": { + "agree": 3360, + "runs": 3360, + "skipped": 1, + "divergences": 0, + "config": "profile=base owner_death=release checked=0 tickless=1;", + "exit": 0 + } + }, + "regressions": { + "agree": 5, + "runs": 5, + "skipped": 0, + "divergences": 0, + "config": "profile=base owner_death=release checked=0 tickless=1;", + "exit": 0 + }, + "config": "profile=base owner_death=release checked=0 tickless=1;", + "total": { + "runs": 33600, + "agree": 33600, + "divergences": 0 + } + } + ], + "finished": "2026-10-30T22:16:03+00:00", + "divergences": 0 +} diff --git a/tools/model/evidence/m2-10/campaign-native-tiny.json b/tools/model/evidence/m2-10/campaign-native-tiny.json new file mode 100644 index 0000000..c6a2e5b --- /dev/null +++ b/tools/model/evidence/m2-10/campaign-native-tiny.json @@ -0,0 +1,116 @@ +{ + "schema": "emblink-campaign", + "version": 1, + "started": "2026-10-30T22:14:22+00:00", + "git_sha": "f563ef5ca99cda5898bfcfb328d5e84afc8df257", + "tracked_dirty": false, + "runs_per_scenario": 40, + "seeds": [ + 1, + 10 + ], + "runners": [ + { + "runner": "build/native-tiny/tests/differential/diff_runner", + "runner_sha256": "eabefa0a305663b524f837add5beafdab83a8dc9a7fb8bb7f08c846c4817e7a7", + "seeds": { + "1": { + "agree": 2440, + "runs": 2440, + "skipped": 24, + "divergences": 0, + "config": "profile=tiny owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "2": { + "agree": 2440, + "runs": 2440, + "skipped": 24, + "divergences": 0, + "config": "profile=tiny owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "3": { + "agree": 2440, + "runs": 2440, + "skipped": 24, + "divergences": 0, + "config": "profile=tiny owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "4": { + "agree": 2440, + "runs": 2440, + "skipped": 24, + "divergences": 0, + "config": "profile=tiny owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "5": { + "agree": 2440, + "runs": 2440, + "skipped": 24, + "divergences": 0, + "config": "profile=tiny owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "6": { + "agree": 2440, + "runs": 2440, + "skipped": 24, + "divergences": 0, + "config": "profile=tiny owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "7": { + "agree": 2440, + "runs": 2440, + "skipped": 24, + "divergences": 0, + "config": "profile=tiny owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "8": { + "agree": 2440, + "runs": 2440, + "skipped": 24, + "divergences": 0, + "config": "profile=tiny owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "9": { + "agree": 2440, + "runs": 2440, + "skipped": 24, + "divergences": 0, + "config": "profile=tiny owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "10": { + "agree": 2440, + "runs": 2440, + "skipped": 24, + "divergences": 0, + "config": "profile=tiny owner_death=fault checked=1 tickless=1;", + "exit": 0 + } + }, + "regressions": { + "agree": 4, + "runs": 4, + "skipped": 1, + "divergences": 0, + "config": "profile=tiny owner_death=fault checked=1 tickless=1;", + "exit": 0 + }, + "config": "profile=tiny owner_death=fault checked=1 tickless=1;", + "total": { + "runs": 24400, + "agree": 24400, + "divergences": 0 + } + } + ], + "finished": "2026-10-30T22:15:36+00:00", + "divergences": 0 +} diff --git a/tools/model/evidence/m2-10/coverage.json b/tools/model/evidence/m2-10/coverage.json new file mode 100644 index 0000000..b26f4f4 --- /dev/null +++ b/tools/model/evidence/m2-10/coverage.json @@ -0,0 +1,139 @@ +{ + "total": { + "lines": 2900, + "lines_hit": 2652, + "branches": 1472, + "branches_taken": 1067, + "line_pct": 91.4, + "branch_pct": 72.5 + }, + "files": { + "kernel/exec/exec.c": { + "lines": 108, + "lines_hit": 87, + "branches": 50, + "branches_taken": 32, + "executed": true + }, + "kernel/fault/fault.c": { + "lines": 38, + "lines_hit": 38, + "branches": 10, + "branches_taken": 9, + "executed": true + }, + "kernel/ipc/msgq.c": { + "lines": 238, + "lines_hit": 220, + "branches": 144, + "branches_taken": 100, + "executed": true + }, + "kernel/irq/irq.c": { + "lines": 32, + "lines_hit": 21, + "branches": 16, + "branches_taken": 13, + "executed": true + }, + "kernel/notify/notify.c": { + "lines": 117, + "lines_hit": 111, + "branches": 66, + "branches_taken": 54, + "executed": true + }, + "kernel/sched/sched.c": { + "lines": 143, + "lines_hit": 135, + "branches": 92, + "branches_taken": 73, + "executed": true + }, + "kernel/sync/cond.c": { + "lines": 124, + "lines_hit": 113, + "branches": 62, + "branches_taken": 40, + "executed": true + }, + "kernel/sync/event.c": { + "lines": 165, + "lines_hit": 152, + "branches": 92, + "branches_taken": 65, + "executed": true + }, + "kernel/sync/mutex.c": { + "lines": 278, + "lines_hit": 242, + "branches": 152, + "branches_taken": 113, + "executed": true + }, + "kernel/sync/sem.c": { + "lines": 144, + "lines_hit": 139, + "branches": 76, + "branches_taken": 54, + "executed": true + }, + "kernel/thread/thread.c": { + "lines": 479, + "lines_hit": 442, + "branches": 266, + "branches_taken": 193, + "executed": true + }, + "kernel/time/time.c": { + "lines": 197, + "lines_hit": 182, + "branches": 76, + "branches_taken": 64, + "executed": true + }, + "kernel/time/timer.c": { + "lines": 257, + "lines_hit": 241, + "branches": 118, + "branches_taken": 83, + "executed": true + }, + "kernel/trace/trace.c": { + "lines": 19, + "lines_hit": 19, + "branches": 2, + "branches_taken": 2, + "executed": true + }, + "kernel/wait/wait.c": { + "lines": 157, + "lines_hit": 147, + "branches": 70, + "branches_taken": 54, + "executed": true + }, + "kernel/work/dwork.c": { + "lines": 89, + "lines_hit": 79, + "branches": 48, + "branches_taken": 29, + "executed": true + }, + "kernel/work/system_workq.c": { + "lines": 10, + "lines_hit": 10, + "branches": 0, + "branches_taken": 0, + "executed": true + }, + "kernel/work/workq.c": { + "lines": 305, + "lines_hit": 274, + "branches": 132, + "branches_taken": 89, + "executed": true + } + }, + "exclusions": {} +} diff --git a/tools/model/evidence/m2-10/matrix-summary.json b/tools/model/evidence/m2-10/matrix-summary.json new file mode 100644 index 0000000..3850be8 --- /dev/null +++ b/tools/model/evidence/m2-10/matrix-summary.json @@ -0,0 +1,211 @@ +{ + "summary": { + "avr-uno": { + "passed": 98, + "failed": 0, + "skipped": 1, + "no assertion": 0, + "no case": 278, + "not run": 0 + }, + "avr-uno-embsim": { + "passed": 98, + "failed": 0, + "skipped": 1, + "no assertion": 0, + "no case": 278, + "not run": 0 + }, + "avr-uno-release": { + "passed": 99, + "failed": 0, + "skipped": 0, + "no assertion": 0, + "no case": 278, + "not run": 0 + }, + "avr-uno-t1": { + "passed": 77, + "failed": 0, + "skipped": 0, + "no assertion": 0, + "no case": 300, + "not run": 0 + }, + "cm3-an385": { + "passed": 124, + "failed": 0, + "skipped": 2, + "no assertion": 0, + "no case": 251, + "not run": 0 + }, + "cm4-an386": { + "passed": 124, + "failed": 0, + "skipped": 2, + "no assertion": 0, + "no case": 251, + "not run": 0 + }, + "cm4-an386-release": { + "passed": 126, + "failed": 0, + "skipped": 0, + "no assertion": 0, + "no case": 251, + "not run": 0 + }, + "cm4-an386-tiny": { + "passed": 100, + "failed": 0, + "skipped": 2, + "no assertion": 0, + "no case": 275, + "not run": 0 + }, + "cm4-nucleo-embsim": { + "passed": 124, + "failed": 0, + "skipped": 2, + "no assertion": 0, + "no case": 251, + "not run": 0 + }, + "native-asan": { + "passed": 124, + "failed": 0, + "skipped": 0, + "no assertion": 0, + "no case": 253, + "not run": 0 + }, + "native-clang": { + "passed": 124, + "failed": 0, + "skipped": 0, + "no assertion": 0, + "no case": 253, + "not run": 0 + }, + "native-coverage": { + "passed": 124, + "failed": 0, + "skipped": 0, + "no assertion": 0, + "no case": 253, + "not run": 0 + }, + "native-gcc": { + "passed": 124, + "failed": 0, + "skipped": 0, + "no assertion": 0, + "no case": 253, + "not run": 0 + }, + "native-periodic": { + "passed": 124, + "failed": 0, + "skipped": 0, + "no assertion": 0, + "no case": 253, + "not run": 0 + }, + "native-release": { + "passed": 124, + "failed": 0, + "skipped": 0, + "no assertion": 0, + "no case": 253, + "not run": 0 + }, + "native-tiny": { + "passed": 101, + "failed": 0, + "skipped": 0, + "no assertion": 0, + "no case": 276, + "not run": 0 + }, + "native-tsan": { + "passed": 124, + "failed": 0, + "skipped": 0, + "no assertion": 0, + "no case": 253, + "not run": 0 + }, + "nucleo-f446re-hil": { + "passed": 0, + "failed": 0, + "skipped": 0, + "no assertion": 0, + "no case": 0, + "not run": 377 + }, + "rv32-c3-embsim": { + "passed": 122, + "failed": 0, + "skipped": 2, + "no assertion": 0, + "no case": 253, + "not run": 0 + }, + "rv32-virt": { + "passed": 122, + "failed": 0, + "skipped": 2, + "no assertion": 0, + "no case": 253, + "not run": 0 + }, + "rv32-virt-release": { + "passed": 124, + "failed": 0, + "skipped": 0, + "no assertion": 0, + "no case": 253, + "not run": 0 + }, + "rv32-virt-tiny": { + "passed": 98, + "failed": 0, + "skipped": 2, + "no assertion": 0, + "no case": 277, + "not run": 0 + } + }, + "uncataloged": [ + "KRN-MEM-009", + "KRN-SCH-001", + "KRN-SCH-002", + "KRN-SCH-003", + "KRN-SCH-004", + "KRN-SCH-008", + "KRN-SCH-009", + "KRN-SCH-016", + "KRN-SCH-017", + "KRN-SCH-018", + "KRN-SCH-019", + "KRN-SCH-021", + "KRN-SCH-041", + "KRN-SCH-043" + ], + "gaps": { + "requirements": 377, + "passing": 127, + "implemented": 39, + "implemented_without_case": [ + "KRN-CAP-013", + "KRN-IPC-021", + "KRN-IPC-022", + "KRN-PART-024", + "KRN-PART-025", + "KRN-PART-026", + "KRN-PART-027", + "KRN-WQ-011" + ] + } +} \ No newline at end of file From 2bb7f90c1dd991fdd6ac6a2f7edc3c63ed6acd59 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Fri, 30 Oct 2026 23:20:40 +0100 Subject: [PATCH 28/30] docs(m2-11): M2 exit review docs/engineering/m2-exit-review.md records: - every M2 checklist item with its pull requests (#21 to #39) and its evidence; - the reconciliation of the public API, annotations, complexity, configuration, model and requirement cards; - the five defects M2 found and where each is fixed; - the CI state; - the limits carried out of M2 (hardware, the tiny profile, primitives not built); - the remaining M3a work. The remaining checklist boxes are ticked. STATUS is reviewed as of the exit: binding sources, object lists, trace records and model coverage are brought up to date, and coverage reports no longer count as unimplemented. The roadmap's M2 entry says what was delivered and what was not, and the configuration guide lists the coverage preset and the kernel object options with their per-profile defaults. Signed-off-by: teo1747 --- CHANGELOG.md | 2 ++ docs/STATUS.md | 12 +++---- docs/architecture/07-roadmap.md | 2 +- docs/engineering/index.md | 1 + docs/engineering/m2-checklist.md | 6 ++-- docs/engineering/m2-exit-review.md | 57 ++++++++++++++++++++++++++++++ docs/user/configuration.md | 16 +++++++++ 7 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 docs/engineering/m2-exit-review.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f08e87f..07ff362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Added +- The M2 exit review (`docs/engineering/m2-exit-review.md`): every checklist item with its pull requests and evidence, the reconciliation of APIs, configuration, model and requirements, the defects found, and the remaining M3a hardware work. + - The M2-10 integrated campaign: combined model scenarios across primitives, a `native-coverage` preset with `tools/coverage/report.py` (statement and branch coverage, run in CI), and `tools/model/campaign.py`, whose 158,800-run summaries are archived under `tools/model/evidence/m2-10/` (see `docs/engineering/m2-10-campaign.md`). - Delayed work (`emb_dwork_t`, M2-09, KRN-WQ-003/007): an item that an internal interrupt-context timer submits to a work queue after a delay or at an instant, with idempotent schedule, reschedule, cancel reporting a stopped delay or removed item as pending, and `cancel_sync`. The queue cannot be destroyed while a delay names it. The reference model, the differential bridge and the work queue conformance suite cover it. diff --git a/docs/STATUS.md b/docs/STATUS.md index 745060a..8f788c5 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Project status and evidence -**Reviewed:** 2026-10-14 against implementation baseline `f0f3afb473a61ebf02a47a9106cf9690f98ece2e`, the documentation foundation merged in `eea5b5d`, and the subsequent user-guide/API-comment review. This inventory is maintained manually until evidence tooling exists. It is not a generated support matrix. +**Reviewed:** 2026-10-30 at the [M2 exit review](engineering/m2-exit-review.md), against the M2 branch stack ending in `docs/m2-11-exit`; previously 2026-10-14 against baseline `f0f3afb473a61ebf02a47a9106cf9690f98ece2e`. This inventory is maintained manually; the [requirement matrix](engineering/case-evidence.md) generated from executed cases complements it and is not a published support matrix. This page is the authoritative inventory of implementation and available evidence. Specifications define target contracts; their acceptance dates do not mean that all described APIs exist. The [roadmap](architecture/07-roadmap.md) determines release eligibility. [Timing and evidence](engineering/timing-and-evidence.md) defines how a claim becomes substantiated. Port pages explain implementation details and historical measurements. @@ -14,19 +14,19 @@ This page is the authoritative inventory of implementation and available evidenc | Communication | Copy-by-value message queues: direct hand-off to a waiting receiver, slot hand-off from a waiting sender, send to front, peek, `EMB_NO_WAIT` calls from ISRs and a receive-side binding (M2-07) | Conformance and native differential bridge; no pipes, buffer pools or zero-copy ownership queues; the masked copy time is bounded by configuration, not measured on hardware | | Software timers | One-shot and drift-free periodic timers in their own deadline list (ADR-047), with callbacks on a work queue or in interrupt context, closed-form catch-up and overrun counts, stop states and `stop_sync` (M2-09) | Conformance and native differential bridge; list scheduler only (not tiny); arming overhead and jitter not measured on hardware | | Deferred work | Work queues on a kernel thread with FIFO execution, idempotent and ISR-safe submit, resubmission while running, cancel and synchronous cancel, flush, destroy joining the thread, a system queue linked only when referenced (M2-08, ADR-048), and delayed work items over internal timers (M2-09) | Conformance and native differential bridge; list scheduler only (not tiny); no statistics | -| Notifications | Per-thread bits and one semaphore-to-thread binding, with unbind; a bound thread cannot be destroyed (ADR-046) | Binding takes a one-bit mask; other objects' bindings arrive with those objects; restart generations need the isolated profile | -| Objects | Caller-owned generated storage and POINTER handles; per-type active-object lists for semaphores and mutexes (`CONFIG_EMB_OBJ_LIST`) used by init | No INDEXED or TABLE handles, capability rights, partition restart, system freeze, `emb_obj_foreach()` or debug-descriptor export | +| Notifications | Per-thread bits; bindings from semaphores, event flags and message queues to a thread, with unbind; a bound thread cannot be destroyed (ADR-046) | Binding takes a one-bit mask; pipes and pools do not exist to bind; restart generations need the isolated profile | +| Objects | Caller-owned generated storage and POINTER handles; per-type active-object lists for every handle object type (`CONFIG_EMB_OBJ_LIST`) used by init to refuse live storage (ADR-045) | No INDEXED or TABLE handles, capability rights, partition restart, system freeze, `emb_obj_foreach()` or debug-descriptor export | | Native port | POSIX host-thread gate, deterministic IRQ injection, virtual time | Base/tiny development workflow; no current wall-clock, asynchronous signal stress, simulated isolated profile or Win32 backend | | Embedded ports | ATmega328P; Cortex-M3/M4F; RV32IMAC and RV32IMC machine mode; boards Arduino Uno, NUCLEO-F446RE, ESP32-C3-DevKitM-1, QEMU's MPS2 and RV32 virt | QEMU jobs and EmbSIM jobs (Uno, NUCLEO-F446RE, ESP32-C3-DevKitM-1) defined in CI; no real-board timing or peripheral/HIL validation recorded here | | Configuration/build | Kconfig subset, port manifests, CMake/Ninja, layout probe, footprint tool | GCC/Clang native CI and cross-GCC QEMU jobs; ten legacy EmbCC capabilities reviewed with scoped local checks, but current RTOS compiler CI/qualification remains incomplete | -| Observability | M1 trace sink and fault reporting | CTF rings/metadata, deferred log decoder, retained crash records, debug descriptor and worst-case monitors remain planned | +| Observability | M1 trace sink and fault reporting; trace records for every outcome of the M2 waits and for queue, work and timer events (IDs 31 to 44) | CTF rings/metadata, deferred log decoder, retained crash records, debug descriptor and worst-case monitors remain planned | | Documentation | Sphinx/MyST manuals and native tutorials, Doxygen/Breathe C API, Sphinx Needs design catalog, JSON/CSV downloads and annotation audit | Strict local HTML build, complete extracted function annotations, source-preservation and catalog-integrity checks; merged `37887c9` Pages deployment and served evidence-workflow page independently observed successful | | Repository process | Issue forms, PR checklist, changelog, release/M2 work checklists, stable CI merge checks, authored-commit DCO checker and a main ruleset policy | Local syntax and DCO/gate failure checks; GitHub's effective main rules report PRs, resolved conversations, the three strict CI checks and deletion/non-fast-forward protection; no remote rejection experiment claimed | -| Semantic model | UP scheduler/wait/mutex/notification/lifecycle scenarios; message queues, modeled before their implementation (KRN-IPC-022), work queues and software timers; exhaustive section-level exploration and randomized walks | Scenario-scoped exploration, not a proof of all C execution, arbitrary programs, SMP or hardware protection | +| Semantic model | UP scheduler/wait/mutex/notification/lifecycle scenarios; binding lifetime, event flags, condition variables, message queues (modeled before their implementation, KRN-IPC-022), work queues, software timers, delayed work and combined scenarios; exhaustive section-level exploration and randomized walks | Scenario-scoped exploration, not a proof of all C execution, arbitrary programs, SMP or hardware protection | ## Specified but unimplemented -Barriers, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; statement/branch coverage reports and the support matrix (executed-case requirement traceability exists, see [case evidence](engineering/case-evidence.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. +Barriers, pipes and buffer pools; indexed handles and capability tables; static partitions, syscall marshallers and supervisor restart; execution budgets and partition shares; full SoC/board/system schema validation and generation; reusable driver classes; power management; boot/update services; full observability formats; a published support matrix (executed-case requirement traceability and kernel statement/branch coverage exist, see [case evidence](engineering/case-evidence.md) and the [M2-10 campaign](engineering/m2-10-campaign.md)); fuzz/HIL campaigns; SMP/AMP ports and transports. An API or path named in a design or a requirement's verification field may be a planned deliverable. Check this inventory and the filesystem before treating it as available. diff --git a/docs/architecture/07-roadmap.md b/docs/architecture/07-roadmap.md index b1da65f..e6c9565 100644 --- a/docs/architecture/07-roadmap.md +++ b/docs/architecture/07-roadmap.md @@ -33,7 +33,7 @@ Continue native and emulator regression testing. Preserve AVR as the tiny-profil The [ordered M2 checklist](../engineering/m2-checklist.md) sequences the known-failure investigations, contract/lifecycle decisions, primitives and evidence review into scoped implementation work. Checklist items are plans, not executed requirement evidence. -Open work: event flags, condition variables, work queues, software timers, message queues; defined binding teardown/lifetime; applicable object lifecycle/freeze behavior; misuse/property campaigns; generated traceability for the implemented core. Pipes, pools, barriers and INDEXED handles have separate feature gates if not needed by the reference application. TABLE handles belong to M3b. +Delivered for review (2026-10-30, [M2 exit review](../engineering/m2-exit-review.md)): event flags, condition variables, copy-by-value message queues, work queues, software timers and delayed work; binding teardown and lifetime; object storage-reuse refusal; the integrated misuse/property/sanitizer/coverage campaign and executed-case traceability. Not in M2: system freeze, pipes, pools, barriers and INDEXED handles (separate feature gates if not needed by the reference application); work queues, timers and condition variables on the tiny profile. TABLE handles belong to M3b. Extend model scenarios and conformance with each primitive, including timeout/cancel/destroy races, ownership conservation and boundary resource counts. Diagnose/minimize the deep replay cases before declaring a campaign clean. Reconcile public annotations with actual complexity and implement the complete resource-bound inventory. diff --git a/docs/engineering/index.md b/docs/engineering/index.md index ed43fb8..7c079cf 100644 --- a/docs/engineering/index.md +++ b/docs/engineering/index.md @@ -19,6 +19,7 @@ m2-02-thread-identity m2-02-fault-api-ids m2-02-object-lifecycle m2-10-campaign +m2-exit-review case-evidence resource-bounds embcc-capability-review diff --git a/docs/engineering/m2-checklist.md b/docs/engineering/m2-checklist.md index 3c72d67..3a8a1ff 100644 --- a/docs/engineering/m2-checklist.md +++ b/docs/engineering/m2-checklist.md @@ -87,8 +87,8 @@ For each new public API: complete annotations and parameter/lifetime docs, gener - [x] Combine timeout, cancel, suspend, destroy, hand-off, priority change and callback delivery at section boundaries, with boundary resource counts and adversarial IRQ/tick placement. - [x] Execute applicable misuse/property/sanitizer campaigns and report statement/branch coverage with exclusions and actual case identity. See the [M2-10 campaign report](m2-10-campaign.md). -- [ ] Reconcile every delivered API's docs, configuration, model, current complexity and implemented requirement scope. -- [ ] Require green current CI plus the scoped individual evidence; no unexplained required failure and no unsupported feature advertised as working. -- [ ] Update STATUS, Unreleased changelog and tutorials, then record the M2 exit review and remaining M3a hardware/release work. +- [x] Reconcile every delivered API's docs, configuration, model, current complexity and implemented requirement scope. See the [M2 exit review](m2-exit-review.md). +- [x] Require green current CI plus the scoped individual evidence; no unexplained required failure and no unsupported feature advertised as working. CI state is recorded in the [exit review](m2-exit-review.md). +- [x] Update STATUS, Unreleased changelog and tutorials, then record the M2 exit review and remaining M3a hardware/release work. Barriers, pipes, buffer pools and INDEXED handles have separate feature gates unless the reference application's agreed scope requires them. TABLE capabilities, partitions/restart, budgets, SMP/AMP and a full driver platform remain later gates. Completing M2 is not a 1.0 release or hardware-support qualification; use the [release checklist](releases.md) when that stage is reached. diff --git a/docs/engineering/m2-exit-review.md b/docs/engineering/m2-exit-review.md new file mode 100644 index 0000000..0c3c2d2 --- /dev/null +++ b/docs/engineering/m2-exit-review.md @@ -0,0 +1,57 @@ +# M2 exit review + +**Date:** 2026-10-30. **Delivered as:** the stacked pull requests #21 to #39 (one logical change each, every one with its own validation), awaiting the project owner's review and merge. **Not done:** anything that needs the NUCLEO-F446RE board. + +## Checklist items + +| Item | Delivered | Pull requests | Evidence | +|---|---|---|---| +| M2-01 known failures | Seed 5 closed as not reproducible (maintainer decision 2026-10-27), with reopen triggers | #23 | [seed-5 triage](m2-01-seed5-triage.md), [timing and evidence](timing-and-evidence.md) | +| M2-02 contracts and lifecycle | Live-storage refusal for every object type (ADR-045), contract corrections A29 to A33, tiny RAM headroom (slim idle ADR-050, thread table and fault-hook savings), clean IDE diagnostics | #21, #22, #24, #25, #26, #27 | [object lifecycle](m2-02-object-lifecycle.md); T1 39 bytes of RAM, TCB 32 | +| M2-03 binding lifetime | Unbind, a bound thread outlives its bindings (ADR-046) | #28 | KRN-NOTIF-013, -014; BINDING model group | +| M2-04 evidence | Individual case evidence and the requirement matrix; the resource-bound inventory and its check | #29, #30 | [case evidence](case-evidence.md), [resource bounds](resource-bounds.md) (16 bounds, 59 functions) | +| M2-05 event flags | ANY/ALL/CLEAR, deterministic consumption, binding | #31 | `test_event`, EVENT group | +| M2-06 condition variables | Atomic release, mandatory non-cancelable relock | #32 | `test_cond`, COND group | +| M2-07 message queues | Model first (#33), then copy-by-value queues with hand-off both ways | #33, #34 | `test_msgq`, MSGQ group | +| M2-08 work queues | Kernel-thread queues, system queue linked only when used (ADR-048) | #35 | `test_workq`, WORKQ group | +| M2-09 software timers | Own deadline list (ADR-047), drift-free periodic, closed-form catch-up, delayed work | #36, #37 | `test_timer`, TIMER and DWORK groups | +| M2-10 integrated campaign | Combined scenarios, 158,800-run campaign, sanitizers, coverage | #38 | [M2-10 campaign](m2-10-campaign.md) | +| M2-11 exit review | This review, STATUS, roadmap, configuration guide | #39 | this page | + +## Reconciliation + +- **Public API.** Every function in `include/emb/` has the complete annotation set (the docs annotation audit passes) and a registered API ID (142 IDs; `tools/fault/generate.py --check`). Each new header is reachable from `` under its `CONFIG_` symbol. +- **Complexity.** Every non-constant `@time` annotation maps to the resource-bound inventory (`tools/evidence/check_bounds.py`: 16 bounds cover 59 functions). +- **Configuration.** Each primitive has a Kconfig symbol, a documented default per profile ([configuration](../user/configuration.md)) and, where it is off on tiny, a dependency or a `footprint-t1.conf` line. The T1 reference image stays at 4,056 bytes of kernel text, 39 bytes of kernel RAM and a 32-byte control block on AVR. +- **Model.** Every delivered primitive has a model group and is followed by the differential bridge: 122 model tests, the catalogue on five native runners. +- **Requirements.** The cards of the delivered requirements say "implemented" with their executed cases; the two implemented ones without a case (KRN-IPC-022, KRN-WQ-011) say what verifies them instead. +- **Contract changes.** Amendments A29 to A61 ([M2 amendments](../specs/AMENDMENTS-M2.md)) and ADR-045 to ADR-048 and ADR-050 record every deviation from the accepted specifications. They are implemented for review: the project owner may still revise them. + +## Defects found during M2 + +| Defect | Found by | Fixed in | +|---|---|---| +| A work queue flush computed its deadline after a tick could advance the clock in its first section | seeded bridge campaign | #35, plus a timer regression | +| The EmbCC native ABI probe lacked `` | inspection while adding work queues | #34 | +| A C function named `reset` could take the RISC-V reset vector's place | a conformance hang on rv32-virt | #36 | +| Raising a ready thread above the caller did not preempt | the M2-10 combined campaign | #38 | +| AddressSanitizer suites could not run on macOS | the M2-10 sanitizer leg | #38 | + +## CI + +At this review, #21 to #37 pass every required check. #38 and #39 were still running, and their results are recorded in the PRs. Three jobs that had hung in their tool installation until GitHub's six-hour limit (on #32, #34 and #35) were re-run and passed. No required check fails unexplained. + +## Limits carried out of M2 + +- **Hardware.** No physical-board run, HIL, masked-time, jitter or latency measurement; arming-overhead compensation is unmeasured. +- **Tiny profile.** No work queues, timers, delayed work or condition variables (maintainer decision for timers, 2026-10-27). +- **Not built.** No pipes, buffer pools, ownership queues, barriers, statistics, system freeze, or INDEXED or TABLE handles. +- **Toolchains.** The EmbCC native flow is not built in CI. The local docs build uses a Doxygen version shim: CI's pinned Doxygen is authoritative. + +## Remaining M3a work + +1. Bring up the NUCLEO-F446RE: run the existing suites on the board, enable kernel-aware IRQ and timer behavior on real hardware, and record the first HIL runner. +2. Measure the masked copy time of a maximum-size message, timer arming overhead and jitter, and work queue latency (KRN-IPC-011, KRN-TIM-006, KRN-WQ-011). +3. Retained crash records and their decoder, trace and debug metadata (SPEC-015). +4. The hardware description generator with one SoC and two boards. +5. Decide, with the project owner, on the amendments and ADRs of M2 before any API is marked stable. diff --git a/docs/user/configuration.md b/docs/user/configuration.md index 500d830..8dc046b 100644 --- a/docs/user/configuration.md +++ b/docs/user/configuration.md @@ -12,6 +12,7 @@ The configuration pipeline resolves Kconfig defaults seeded by a port manifest, | `native-periodic` | GCC, checked base with a periodic clock for timer/wait validation | | `native-clang` | Clang compiler coverage; requires Clang | | `native-asan` / `native-tsan` | Sanitizer configurations; require supported compiler runtimes | +| `native-coverage` | GCC `--coverage` build; after CTest, `tools/coverage/report.py` prints kernel statement and branch coverage | Use the same preset name for configure, build and CTest. A tiny result does not verify base-only features; a release result does not exercise checked-build faults. Embedded presets additionally require their cross-compiler, libc and emulator; do not interpret a build with no registered tests as conformance. @@ -19,6 +20,21 @@ Onboarding validated the first three GCC presets in this cloud machine. Clang/sa The later [timer-delivery validation](../engineering/m2-01-timer-correction.md) also exercised `native-periodic` and added exact timer-boundary regressions to every applicable native CTest build. Its recorded outcomes distinguish periodic and tickless runs. +## Kernel object options + +| Option | Default | Notes | +|---|---|---| +| `CONFIG_EMB_EVENT`, `CONFIG_EMB_EVENT_BITS` | y; 32 bits (8 on tiny) | event flags | +| `CONFIG_EMB_COND` | y (n on tiny) | condition variables; needs `CONFIG_EMB_MUTEX` | +| `CONFIG_EMB_MSGQ`, `CONFIG_EMB_MSGQ_MAX_ITEM` | y; 64 bytes (16 on tiny) | message queues; the item bound limits the copy made with interrupts masked | +| `CONFIG_EMB_IPC_SMALL` | n (y on tiny) | 8-bit queue counts, at most 255 items | +| `CONFIG_EMB_WORKQ` | y | work queues and, with timers, delayed work; not available with the tiny profile's table scheduler | +| `CONFIG_EMB_SYSTEM_WORKQ`, `_PRIO`, `_STACK` | y; 0 (second-highest priority); 2048 bytes | the system work queue, linked only when `emb_workq_system()` is referenced | +| `CONFIG_EMB_TIMER` | y | software timers; not available with the table scheduler | +| `CONFIG_EMB_OBJ_LIST` | y (n on tiny) | exact refusal of init on live storage | + +The tiny `footprint_t1` reference configuration turns event flags and message queues off. + ## Add diagnostic options without changing the reference preset Activate native tools, then configure a separate ignored build directory. `EMB_SET` uses Kconfig names without the generated `CONFIG_` prefix; quote the semicolon-separated list so the shell passes one argument: From db6d36f045d8c80bdf83641a05f18c26da0b1b23 Mon Sep 17 00:00:00 2001 From: teo1747 Date: Fri, 30 Oct 2026 23:31:41 +0100 Subject: [PATCH 29/30] docs(m2-11): record the final CI state Signed-off-by: teo1747 --- docs/engineering/m2-exit-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/engineering/m2-exit-review.md b/docs/engineering/m2-exit-review.md index 0c3c2d2..920f768 100644 --- a/docs/engineering/m2-exit-review.md +++ b/docs/engineering/m2-exit-review.md @@ -39,7 +39,7 @@ ## CI -At this review, #21 to #37 pass every required check. #38 and #39 were still running, and their results are recorded in the PRs. Three jobs that had hung in their tool installation until GitHub's six-hour limit (on #32, #34 and #35) were re-run and passed. No required check fails unexplained. +At this review, #21 to #39 pass every required check, including the new `native-coverage` job. Three jobs that had hung in their tool installation until GitHub's six-hour limit (on #32, #34 and #35) were re-run and passed. No required check fails unexplained. ## Limits carried out of M2 From 8d2cb2a8f0e235b0285be504bcc6e1adb80ca86f Mon Sep 17 00:00:00 2001 From: teo1747 Date: Sat, 31 Oct 2026 00:33:42 +0100 Subject: [PATCH 30/30] docs(m2): record the accepted M2 contract decisions The project owner reviewed the M2 contract changes by topic on 2026-10-31 and accepted all of them as written: amendments A29 to A61, including A55's explicit timer queue, and ADR-045, ADR-046, ADR-047, ADR-048 and ADR-050. The ADR statuses, the amendments page, the exit review, the roadmap and the changelog now say so. Signed-off-by: teo1747 --- CHANGELOG.md | 2 ++ docs/architecture/06-decision-records.md | 10 +++++----- docs/architecture/07-roadmap.md | 2 +- docs/engineering/m2-exit-review.md | 5 ++--- docs/specs/AMENDMENTS-M2.md | 2 ++ 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ff362..5e3479c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ User-visible changes are recorded here. There are no released versions or releas ### Added +- The M2 contract decisions are accepted: amendments A29 to A61 and ADR-045 to ADR-048 and ADR-050 (project-owner review, 2026-10-31). + - The M2 exit review (`docs/engineering/m2-exit-review.md`): every checklist item with its pull requests and evidence, the reconciliation of APIs, configuration, model and requirements, the defects found, and the remaining M3a hardware work. - The M2-10 integrated campaign: combined model scenarios across primitives, a `native-coverage` preset with `tools/coverage/report.py` (statement and branch coverage, run in CI), and `tools/model/campaign.py`, whose 158,800-run summaries are archived under `tools/model/evidence/m2-10/` (see `docs/engineering/m2-10-campaign.md`). diff --git a/docs/architecture/06-decision-records.md b/docs/architecture/06-decision-records.md index 624101f..407d2e9 100644 --- a/docs/architecture/06-decision-records.md +++ b/docs/architecture/06-decision-records.md @@ -538,7 +538,7 @@ Each record: context, decision, alternatives, consequences, status. **Proposed** **Consequences.** Each semaphore and mutex grows by one list node (two pointers) where the list is configured; the generated storage types follow automatically. Init costs a walk of the active objects of its type with interrupts masked. Tiny keeps its RAM, flash and init cost, and its semaphore/mutex double init stays undefined. New object types (event flags, condition variables, queues, work queues, timers) join the same scheme. POINTER handles still carry no generation: a stale copy of a handle to destroyed and re-initialized storage refers to the new object (SPEC-009 §3.1). -**Status.** Implemented for review, 2026-10-27, as part of completing M2-02; awaiting project-owner review. +**Status.** Accepted 2026-10-31 by the project owner at the M2 contract review; implemented 2026-10-27 in M2-02. ## ADR-050 Slim idle context on the table profile @@ -550,7 +550,7 @@ Each record: context, decision, alternatives, consequences, status. **Proposed** **Consequences.** AVR `footprint_t1` kernel static RAM falls from 64 to 43 bytes and its kernel text by 44 bytes (avr-gcc 14.4.0); Cortex-M, RV32 and native tiny builds shrink by their own prefix sizes. The definition's type differs from the declaration on purpose, a recorded MISRA rule 8.3/8.4 deviation that requires the kernel not to be built with LTO (which would report it). KRN-THR-026 now makes idle unreachable rather than refusing operations with `EMB_EPERM`. Code running in the idle context, such as a port's idle hook, must not call thread-context API, which would reach fields the slim idle context does not have. Traces and fault records still name idle by index 0xFE. A field moved in front of `wait_gen`, or a new idle access, must keep the static assertions in `idle_context.c` true. -**Status.** Implemented for review, 2026-10-27, as part of the M2 tiny-RAM headroom work; awaiting project-owner review. +**Status.** Accepted 2026-10-31 by the project owner at the M2 contract review; implemented 2026-10-27 in the M2 tiny-RAM headroom work. ## ADR-046 A bound thread outlives its bindings @@ -562,7 +562,7 @@ Each record: context, decision, alternatives, consequences, status. **Proposed** **Consequences.** Thread destroy costs one byte comparison; bind and unbind are O(1). The base TCB size is unchanged (the byte fills padding) and the tiny TCB stays at 32 bytes on AVR. Code that destroyed a bound thread before must now unbind or destroy the object first; checked builds fault where they used to corrupt silently. The reference model checks that no binding names a destroyed thread, and its bind, unbind and thread-destroy transitions agree with the kernel in the differential bridge. -**Status.** Implemented for review, 2026-10-27, as M2-03; awaiting project-owner review. +**Status.** Accepted 2026-10-31 by the project owner at the M2 contract review; implemented 2026-10-27 in M2-03. ## ADR-048 Work queue threads are kernel threads; the system queue is linked only when used @@ -574,7 +574,7 @@ Each record: context, decision, alternatives, consequences, status. **Proposed** **Consequences.** M1 images and traces are unchanged, and the footprint of an image that does not use work queues does not grow. An application that references the system queue from a translation unit also gets its stack; one that only creates its own queues does not. Work queue destroy blocks until the thread has switched out, so it is thread-only. KRN-WQ-008's "exists from kernel start" holds for every image that can name the queue. The table scheduler has no work queues in M2 (A53). -**Status.** Implemented for review, 2026-10-27, as part of M2-08; awaiting project-owner review. +**Status.** Accepted 2026-10-31 by the project owner at the M2 contract review; implemented 2026-10-27 in M2-08. ## ADR-047 Software timers keep their own deadline list @@ -586,4 +586,4 @@ Each record: context, decision, alternatives, consequences, status. **Proposed** **Consequences.** With `CONFIG_EMB_TIMER=n` the thread timeout path is the M1 code. Arming a timer is O(armed timers); stopping is O(1). A timer restarted already due from its own ISR-context callback expires one pass later: at the next tick in periodic mode, at once by the reprogrammed compare in tickless mode. The table scheduler's singly linked list has no timer list, and timers are off with it in M2. -**Status.** Implemented for review, 2026-10-27, as part of M2-09; awaiting project-owner review. +**Status.** Accepted 2026-10-31 by the project owner at the M2 contract review; implemented 2026-10-27 in M2-09. diff --git a/docs/architecture/07-roadmap.md b/docs/architecture/07-roadmap.md index e6c9565..4884da8 100644 --- a/docs/architecture/07-roadmap.md +++ b/docs/architecture/07-roadmap.md @@ -33,7 +33,7 @@ Continue native and emulator regression testing. Preserve AVR as the tiny-profil The [ordered M2 checklist](../engineering/m2-checklist.md) sequences the known-failure investigations, contract/lifecycle decisions, primitives and evidence review into scoped implementation work. Checklist items are plans, not executed requirement evidence. -Delivered for review (2026-10-30, [M2 exit review](../engineering/m2-exit-review.md)): event flags, condition variables, copy-by-value message queues, work queues, software timers and delayed work; binding teardown and lifetime; object storage-reuse refusal; the integrated misuse/property/sanitizer/coverage campaign and executed-case traceability. Not in M2: system freeze, pipes, pools, barriers and INDEXED handles (separate feature gates if not needed by the reference application); work queues, timers and condition variables on the tiny profile. TABLE handles belong to M3b. +Delivered 2026-10-30 and its contracts accepted 2026-10-31 ([M2 exit review](../engineering/m2-exit-review.md)): event flags, condition variables, copy-by-value message queues, work queues, software timers and delayed work; binding teardown and lifetime; object storage-reuse refusal; the integrated misuse/property/sanitizer/coverage campaign and executed-case traceability. Not in M2: system freeze, pipes, pools, barriers and INDEXED handles (separate feature gates if not needed by the reference application); work queues, timers and condition variables on the tiny profile. TABLE handles belong to M3b. Extend model scenarios and conformance with each primitive, including timeout/cancel/destroy races, ownership conservation and boundary resource counts. Diagnose/minimize the deep replay cases before declaring a campaign clean. Reconcile public annotations with actual complexity and implement the complete resource-bound inventory. diff --git a/docs/engineering/m2-exit-review.md b/docs/engineering/m2-exit-review.md index 920f768..46873ac 100644 --- a/docs/engineering/m2-exit-review.md +++ b/docs/engineering/m2-exit-review.md @@ -1,6 +1,6 @@ # M2 exit review -**Date:** 2026-10-30. **Delivered as:** the stacked pull requests #21 to #39 (one logical change each, every one with its own validation), awaiting the project owner's review and merge. **Not done:** anything that needs the NUCLEO-F446RE board. +**Date:** 2026-10-30. **Delivered as:** the stacked pull requests #21 to #39 (one logical change each, every one with its own validation), reviewed and merged by the project owner; the stack reaches `main` through #40. **Not done:** anything that needs the NUCLEO-F446RE board. ## Checklist items @@ -25,7 +25,7 @@ - **Configuration.** Each primitive has a Kconfig symbol, a documented default per profile ([configuration](../user/configuration.md)) and, where it is off on tiny, a dependency or a `footprint-t1.conf` line. The T1 reference image stays at 4,056 bytes of kernel text, 39 bytes of kernel RAM and a 32-byte control block on AVR. - **Model.** Every delivered primitive has a model group and is followed by the differential bridge: 122 model tests, the catalogue on five native runners. - **Requirements.** The cards of the delivered requirements say "implemented" with their executed cases; the two implemented ones without a case (KRN-IPC-022, KRN-WQ-011) say what verifies them instead. -- **Contract changes.** Amendments A29 to A61 ([M2 amendments](../specs/AMENDMENTS-M2.md)) and ADR-045 to ADR-048 and ADR-050 record every deviation from the accepted specifications. They are implemented for review: the project owner may still revise them. +- **Contract changes.** Amendments A29 to A61 ([M2 amendments](../specs/AMENDMENTS-M2.md)) and ADR-045 to ADR-048 and ADR-050 record every deviation from the accepted specifications. The project owner reviewed and accepted all of them, as written, on 2026-10-31. ## Defects found during M2 @@ -54,4 +54,3 @@ At this review, #21 to #39 pass every required check, including the new `native- 2. Measure the masked copy time of a maximum-size message, timer arming overhead and jitter, and work queue latency (KRN-IPC-011, KRN-TIM-006, KRN-WQ-011). 3. Retained crash records and their decoder, trace and debug metadata (SPEC-015). 4. The hardware description generator with one SoC and two boards. -5. Decide, with the project owner, on the amendments and ADRs of M2 before any API is marked stable. diff --git a/docs/specs/AMENDMENTS-M2.md b/docs/specs/AMENDMENTS-M2.md index b3bf6ff..3885ae6 100644 --- a/docs/specs/AMENDMENTS-M2.md +++ b/docs/specs/AMENDMENTS-M2.md @@ -2,6 +2,8 @@ Corrections found while preparing the M2 primitives, where an accepted specification, requirement or decision record contradicts another or the implemented behavior. Each is applied in the affected document; this page keeps the audit trail. Numbering continues from the [M1 record](AMENDMENTS-M1.md). Decisions about the new M2 primitives themselves are recorded separately, as they are taken. +**Accepted:** A29 to A61 were reviewed by topic with the project owner on 2026-10-31 and accepted as written, including A55's explicit timer queue (a null queue stays `EMB_ENOTSUP` rather than meaning the system queue). ADR-045 to ADR-048 and ADR-050 were accepted at the same review. + | # | Document | Amendment | Where in the code | |---|---|---|---| | A29 | KRN-TIM-035, SPEC-003 §12 | The requirement's last clause, `EMB_EOVERFLOW` for a `_until` deadline out of the 32-bit profile's range, contradicts A2 and KRN-TIM-022: such a deadline is indistinguishable from a past instant and takes the past-deadline path. The clause is removed; the checked-build timer detections remain. | `kernel/thread/thread.c`, `kernel/sync/*.c`, `kernel/notify/notify.c` |