diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index b2b4b217c..99108ad99 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -818,6 +818,8 @@ jobs: python3 -B test/test_nrf_radio_yield_budget.py -v python3 -B test/test_nrf_radio_priority_lease.py -v python3 -B test/test_nrf_radio_priority_integration.py -v + python3 -B test/test_nrf_radio_usb_irq_guard.py -v + python3 -B test/test_nrf_radio_spi_startup_diag.py -v python3 -B test/test_sx126x_nrf_rp_constructors.py -v python3 -B test/test_esp32_buffered_radio_targets.py -v python3 -B test/test_xiao_battery_safety.py -v diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index b0dc781d0..9be20e480 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -120,7 +120,8 @@ class CustomSX1262 : public SX1262 { void beginProfileSwitch(bool continuousRx) { _profileSwitch.begin(continuousRx, standbyXOSC); } void endProfileSwitch(bool success) { _profileSwitch.end(success); } bool profileSwitchFailed() const { return _profileSwitch.failed(); } -#if defined(MC_NRF_RADIO_PROFILE_PRIORITY) && MC_NRF_RADIO_PROFILE_PRIORITY +#if (defined(MC_NRF_RADIO_PROFILE_PRIORITY) && MC_NRF_RADIO_PROFILE_PRIORITY) \ + || (defined(MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD) && MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD) bool hilOwnsWarmProfileRetune() const { return _profileSwitch.open && _profileSwitch.active && _profileSwitch.rxValid && !_coldStandby; diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 1315e5650..fa2ecb1c5 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -24,7 +24,8 @@ protected: void endProfileRetune(bool success) override { static_cast(_radio)->endProfileSwitch(success); } -#if defined(MC_NRF_RADIO_PROFILE_PRIORITY) && MC_NRF_RADIO_PROFILE_PRIORITY +#if (defined(MC_NRF_RADIO_PROFILE_PRIORITY) && MC_NRF_RADIO_PROFILE_PRIORITY) \ + || (defined(MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD) && MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD) bool ownsWarmProfileRetune() const override { return static_cast(_radio)->hilOwnsWarmProfileRetune(); } diff --git a/src/helpers/radiolib/Nrf52BufferedRadioHal.h b/src/helpers/radiolib/Nrf52BufferedRadioHal.h index 56aa7bd37..fcc4d51bb 100644 --- a/src/helpers/radiolib/Nrf52BufferedRadioHal.h +++ b/src/helpers/radiolib/Nrf52BufferedRadioHal.h @@ -8,6 +8,12 @@ #if MC_NRF_RADIO_PROFILE_PRIORITY #include "NrfRadioPriorityLease.h" #endif +#ifndef MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD +#define MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD 0 +#endif +#if MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD +#include "NrfRadioUsbIrqGuard.h" +#endif #ifndef MC_NRF_RADIO_BUSY_SPIN_POLLS #define MC_NRF_RADIO_BUSY_SPIN_POLLS 0 @@ -27,11 +33,16 @@ public: explicit Nrf52BufferedRadioHal(SPIClass& bus, uint32_t hz = 16000000) : ArduinoHal(bus, SPISettings(hz, MSBFIRST, SPI_MODE0)) {} -#if MC_NRF_RADIO_PROFILE_PRIORITY +#if MC_NRF_RADIO_PROFILE_PRIORITY || MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD void spiBeginTransaction() override { // Check before RadioLib lowers CS, so restoring task priority does not // suspend this task with a newly selected, unclocked radio command. +#if MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD + mesh::radiousb::beforeFrame(); +#endif +#if MC_NRF_RADIO_PROFILE_PRIORITY mesh::radiopriority::beforeFrame(); +#endif ArduinoHal::spiBeginTransaction(); } #endif @@ -44,13 +55,21 @@ public: #endif } -#if MC_NRF_RADIO_BUSY_SPIN_POLLS > 0 || MC_NRF_RADIO_PROFILE_PRIORITY +#if MC_NRF_RADIO_BUSY_SPIN_POLLS > 0 || MC_NRF_RADIO_PROFILE_PRIORITY || MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD // Diagnostic only, off by default. RadioLib calls yield() during SPI BUSY // polls and other waits. Spend at most N non-yielding polls after a real // frame, then delegate every call until another frame resets the budget. // An unused budget can reach a later non-BUSY wait; this is not BUSY-only // detection. No GPIO reads, status checks, timeouts or interrupts change. void yield() override { +#if MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD + if (mesh::radiousb::beforeYield()) { + // Normal Arduino yield flushes TinyUSB CDC. While its IRQ is deferred, + // retain task scheduling without entering that USB flush/mutex path. + taskYIELD(); + return; + } +#endif #if MC_NRF_RADIO_PROFILE_PRIORITY const auto decision = mesh::radiopriority::beforeYield(); if (decision == mesh::radiopriority::Suppress) return; diff --git a/src/helpers/radiolib/NrfRadioUsbIrqGuard.cpp b/src/helpers/radiolib/NrfRadioUsbIrqGuard.cpp new file mode 100644 index 000000000..5babd59de --- /dev/null +++ b/src/helpers/radiolib/NrfRadioUsbIrqGuard.cpp @@ -0,0 +1,16 @@ +#include "NrfRadioUsbIrqGuard.h" + +#if MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD +namespace mesh { namespace radiousb { +extern "C" { +volatile Capture mesh_hil_radio_usb_irq = { + 0x4d435255, 1, sizeof(Capture), DeadlineTicks, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; +} +Work work; +} } + +extern "C" void RTC2_IRQHandler(void) { + mesh::radiousb::interrupt(); +} +#endif diff --git a/src/helpers/radiolib/NrfRadioUsbIrqGuard.h b/src/helpers/radiolib/NrfRadioUsbIrqGuard.h new file mode 100644 index 000000000..b7173a9f3 --- /dev/null +++ b/src/helpers/radiolib/NrfRadioUsbIrqGuard.h @@ -0,0 +1,249 @@ +#pragma once + +#ifndef MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD +#define MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD 0 +#endif +#if MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD != 0 && MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD != 1 +#error "Radio USB IRQ experiment must be 0 or 1" +#endif +#if MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD +#if !defined(NRF52_PLATFORM) || !defined(MESH_HIL_PROFILE_TIMING) || !MESH_HIL_PROFILE_TIMING +#error "Radio USB IRQ experiment requires nRF52 HIL profile timing" +#endif +#if defined(MC_NRF_RADIO_PROFILE_PRIORITY) && MC_NRF_RADIO_PROFILE_PRIORITY +#error "Measure the USB IRQ and task-priority experiments separately" +#endif +#include +#include +#include +#include +#include +#include +#include "../nrf52/SoftDeviceState.h" +#if defined(NRFX_RTC2_ENABLED) && NRFX_RTC2_ENABLED +#error "Radio USB IRQ experiment cannot replace an nrfx RTC2 owner" +#endif + +namespace mesh { namespace radiousb { + +static constexpr uint32_t DeadlineTicks = 32; // about 0.977 ms at 32768 Hz +static constexpr uint32_t RtcMask = 0x00ffffff; +static constexpr uint32_t TimerPriority = 2; // SDK application IRQ; no RTOS API + +// Private independent single-word observations, not an atomic snapshot. +struct Capture { + uint32_t magic, version, size_bytes, deadline_ticks; + uint32_t active, entries, releases, expired; + uint32_t skipped_not_owned, skipped_isr, skipped_nested, skipped_task; + uint32_t skipped_interrupts, skipped_usb, skipped_timer, skipped_priority, skipped_sdk; + uint32_t last_sdk_result, last_sdk_enabled, last_nvic_result; + uint32_t restore_errors, task_yields, frame_checks, usb_reenabled; +}; +struct Work { + TaskHandle_t owner = nullptr; + uint32_t generation = 0, started_ticks = 0, cc0 = 0; + uint32_t rtc_priority = 0, usb_priority = 0, inten = 0, evten = 0; + std::atomic active{0}; + std::atomic usb_masked{0}; + bool softdevice_enabled = false; +}; +extern "C" { +extern volatile Capture mesh_hil_radio_usb_irq; +} +extern Work work; + +inline bool legalAppPriority(uint32_t priority) { + return priority < 8 && priority != 0 && priority != 1 && priority != 4; +} + +// The SDK app-NVIC helpers are inline, not SVCs, including in RTC2's ISR. +// SD-off may restore the unused timer's original priority zero exactly. +inline uint32_t setPriority(IRQn_Type irq, uint32_t priority) { + if (work.softdevice_enabled) return sd_nvic_SetPriority(irq, priority); + NVIC_SetPriority(irq, priority); + return NRF_SUCCESS; +} +inline uint32_t enableIrq(IRQn_Type irq) { + if (work.softdevice_enabled) return sd_nvic_EnableIRQ(irq); + NVIC_EnableIRQ(irq); + return NRF_SUCCESS; +} +inline uint32_t disableIrq(IRQn_Type irq) { + if (work.softdevice_enabled) return sd_nvic_DisableIRQ(irq); + NVIC_DisableIRQ(irq); + return NRF_SUCCESS; +} +inline uint32_t clearTimerPending() { + if (work.softdevice_enabled) return sd_nvic_ClearPendingIRQ(RTC2_IRQn); + NVIC_ClearPendingIRQ(RTC2_IRQn); + return NRF_SUCCESS; +} +inline void restored(uint32_t result) { + mesh_hil_radio_usb_irq.last_nvic_result = result; + if (result != NRF_SUCCESS) ++mesh_hil_radio_usb_irq.restore_errors; +} + +inline void release(bool expired = false) { + if (!work.active.load(std::memory_order_acquire)) return; + // Mask only our timer IRQ. Its ISR can finish first if it interrupted this + // call before the disable; recheck before touching the saved state. + restored(disableIrq(RTC2_IRQn)); + if (!work.active.load(std::memory_order_acquire)) return; + if (work.usb_masked.load(std::memory_order_acquire) && NVIC_GetEnableIRQ(USBD_IRQn)) + ++mesh_hil_radio_usb_irq.usb_reenabled; + work.usb_masked.store(0, std::memory_order_release); + work.active.store(0, std::memory_order_release); + mesh_hil_radio_usb_irq.active = 0; + ++mesh_hil_radio_usb_irq.releases; + if (expired) ++mesh_hil_radio_usb_irq.expired; + nrf_rtc_int_disable(NRF_RTC2, RTC_INTENSET_COMPARE0_Msk); + nrf_rtc_event_clear(NRF_RTC2, NRF_RTC_EVENT_COMPARE_0); + restored(clearTimerPending()); + nrf_rtc_cc_set(NRF_RTC2, 0, work.cc0); + // Admission requires all RTC2 interrupt/PPI event enables to be zero; + // only COMPARE0's interrupt was changed. Counter/prescaler never change. + restored(setPriority(RTC2_IRQn, work.rtc_priority)); + restored(setPriority(USBD_IRQn, work.usb_priority)); + // Keep every pending USB event. Re-enabling lets the normal driver drain + // it; no USBD peripheral registers, endpoint state or buffers are changed. + restored(enableIrq(USBD_IRQn)); + work.owner = nullptr; +} + +inline bool isOwner() { + return work.active.load(std::memory_order_acquire) && !isInISR() + && work.owner == xTaskGetCurrentTaskHandle(); +} +inline bool checkDeadline() { + if (!isOwner()) return false; + // A USB reconfiguration/power event may re-enable its IRQ independently. + // End the experiment rather than silently count that hop as isolated. + if (work.usb_masked.load(std::memory_order_acquire) && NVIC_GetEnableIRQ(USBD_IRQn)) { + release(); + return false; + } + if (((NRF_RTC2->COUNTER - work.started_ticks) & RtcMask) < DeadlineTicks) + return true; + release(true); // secondary check if an IRQ was briefly delayed + return false; +} +inline void beforeFrame() { + if (!isOwner()) return; + ++mesh_hil_radio_usb_irq.frame_checks; + (void)checkDeadline(); +} +inline bool beforeYield() { + if (!checkDeadline()) return false; + ++mesh_hil_radio_usb_irq.task_yields; + return true; +} + +inline uint32_t enter(bool owned_warm_rx) { + auto& capture = mesh_hil_radio_usb_irq; + if (!owned_warm_rx) { ++capture.skipped_not_owned; return 0; } + if (isInISR()) { ++capture.skipped_isr; return 0; } + if (work.active.load(std::memory_order_acquire)) { ++capture.skipped_nested; return 0; } + const TaskHandle_t owner = xTaskGetCurrentTaskHandle(); + const char* name = pcTaskGetName(owner); + if (!name || strcmp(name, "loop") != 0 || uxTaskPriorityGet(owner) != TASK_PRIO_LOW) { + ++capture.skipped_task; + return 0; + } + if (__get_PRIMASK() || __get_BASEPRI() || __get_FAULTMASK() || nrf_nvic_state.__cr_flag) { + ++capture.skipped_interrupts; + return 0; + } + uint8_t enabled = 0; + capture.last_sdk_result = mesh_nrf52::softdeviceIsEnabled(enabled); + capture.last_sdk_enabled = enabled; + if (capture.last_sdk_result != NRF_SUCCESS) { ++capture.skipped_sdk; return 0; } + if (!NVIC_GetEnableIRQ(USBD_IRQn)) { ++capture.skipped_usb; return 0; } + if (NVIC_GetEnableIRQ(RTC2_IRQn) || NVIC_GetPendingIRQ(RTC2_IRQn) + || NRF_RTC2->INTENSET || NRF_RTC2->EVTEN || NRF_RTC2->PRESCALER) { + ++capture.skipped_timer; + return 0; + } + for (unsigned channel = 0; channel < 4; ++channel) { + if (NRF_RTC2->EVENTS_COMPARE[channel]) { ++capture.skipped_timer; return 0; } + } + work.rtc_priority = NVIC_GetPriority(RTC2_IRQn); + work.usb_priority = NVIC_GetPriority(USBD_IRQn); + work.softdevice_enabled = enabled != 0; + if (enabled && (!legalAppPriority(work.rtc_priority) || !legalAppPriority(work.usb_priority))) { + ++capture.skipped_priority; + return 0; + } + work.owner = owner; + work.usb_masked.store(0, std::memory_order_release); + work.cc0 = nrf_rtc_cc_get(NRF_RTC2, 0); + work.inten = NRF_RTC2->INTENSET; + work.evten = NRF_RTC2->EVTEN; + work.started_ticks = NRF_RTC2->COUNTER & RtcMask; + if (++work.generation == 0) ++work.generation; + const uint32_t generation = work.generation; + capture.last_nvic_result = setPriority(RTC2_IRQn, TimerPriority); + if (capture.last_nvic_result != NRF_SUCCESS) { + ++capture.skipped_sdk; + work.owner = nullptr; + return 0; + } + nrf_rtc_cc_set(NRF_RTC2, 0, (work.started_ticks + DeadlineTicks) & RtcMask); + nrf_rtc_event_clear(NRF_RTC2, NRF_RTC_EVENT_COMPARE_0); + work.active.store(1, std::memory_order_release); + capture.active = 1; + ++capture.entries; + nrf_rtc_int_enable(NRF_RTC2, RTC_INTENSET_COMPARE0_Msk); + capture.last_nvic_result = enableIrq(RTC2_IRQn); + if (capture.last_nvic_result != NRF_SUCCESS) { + ++capture.skipped_sdk; + release(); + return 0; + } + if (!checkDeadline()) return 0; + capture.last_nvic_result = disableIrq(USBD_IRQn); + if (capture.last_nvic_result != NRF_SUCCESS) { + ++capture.skipped_sdk; + release(); + return 0; + } + work.usb_masked.store(1, std::memory_order_release); + // An interrupt can expire between arming the timer and masking USB. Never + // return with USB disabled after the expiry ISR has already restored it. + if (!work.active.load(std::memory_order_acquire)) { + work.usb_masked.store(0, std::memory_order_release); + restored(enableIrq(USBD_IRQn)); + return 0; + } + if (!checkDeadline()) return 0; + return generation; +} + +inline void interrupt() { + // Only our admitted COMPARE0 event is acknowledged. Existing timer owners + // are rejected before admission; we never enable another RTC2 source. + if (work.active.load(std::memory_order_acquire) + && (NRF_RTC2->INTENSET & RTC_INTENSET_COMPARE0_Msk) + && nrf_rtc_event_check(NRF_RTC2, NRF_RTC_EVENT_COMPARE_0)) release(true); +} + +// Diagnostic causal isolation only. The timer restores USB even if an nrfx +// blocking transfer never returns. Higher-priority IRQs can delay that ISR; +// this is not an absolute hard real-time bound, nor a production policy. +class Guard { + uint32_t _generation; +public: + explicit Guard(bool owned_warm_rx) : _generation(enter(owned_warm_rx)) {} + Guard(const Guard&) = delete; + Guard& operator=(const Guard&) = delete; + ~Guard() { release(); } + void release() { + if (!_generation) return; + const uint32_t generation = _generation; + _generation = 0; + if (work.generation == generation) radiousb::release(); + } +}; + +static_assert(sizeof(Capture) == 96, "Unexpected USB IRQ diagnostic ABI"); +} } // namespace mesh::radiousb +#endif diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index b2eecd31d..533a788b6 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -5,6 +5,7 @@ #include "RadioProfileChip.h" #include "ProfileSwitchTiming.h" #include "NrfRadioPriorityLease.h" +#include "NrfRadioUsbIrqGuard.h" #if MESH_HIL_PROFILE_TIMING namespace mesh { namespace profiletiming { extern "C" { @@ -647,12 +648,19 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { #if MC_NRF_RADIO_PROFILE_PRIORITY mesh::radiopriority::Lease profile_priority(dual_profile && _active_profile != profile && ownsWarmProfileRetune()); +#endif +#if MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD + mesh::radiousb::Guard profile_usb_irq(dual_profile && _active_profile != profile + && ownsWarmProfileRetune()); #endif const uint8_t resume = beginReconfigure(); if (resume > 1) { endProfileRetune(resume == 2); // Only a BUSY guard deferred before changing hardware. #if MESH_HIL_PROFILE_TIMING const uint32_t finished_ticks = mesh::profiletiming::readClock(); +#if MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD + profile_usb_irq.release(); +#endif #if MC_NRF_RADIO_PROFILE_PRIORITY profile_priority.release(); #endif @@ -706,6 +714,9 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { // Finish before rollback: restoration is a different operation and must // not overwrite the failed hop's partial stage evidence. const uint32_t finished_ticks = mesh::profiletiming::readClock(); +#if MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD + profile_usb_irq.release(); +#endif #if MC_NRF_RADIO_PROFILE_PRIORITY profile_priority.release(); #endif @@ -759,6 +770,9 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { #if MESH_HIL_PROFILE_TIMING mesh::profiletiming::mark(mesh::profiletiming::Tail, mesh::profiletiming::readClock()); const uint32_t finished_ticks = mesh::profiletiming::readClock(); +#if MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD + profile_usb_irq.release(); +#endif #if MC_NRF_RADIO_PROFILE_PRIORITY profile_priority.release(); #endif diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 6a9306266..b597d86c4 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -387,7 +387,8 @@ protected: virtual void setProfileStandbyWarm(bool) {} virtual void beginProfileRetune(bool) {} virtual void endProfileRetune(bool) {} -#if defined(MC_NRF_RADIO_PROFILE_PRIORITY) && MC_NRF_RADIO_PROFILE_PRIORITY +#if (defined(MC_NRF_RADIO_PROFILE_PRIORITY) && MC_NRF_RADIO_PROFILE_PRIORITY) \ + || (defined(MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD) && MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD) virtual bool ownsWarmProfileRetune() const { return false; } #endif // 0 = reconfigure from idle, 1 = resume RX afterwards, 2 = currently busy. diff --git a/test/test_nrf_radio_priority_integration.py b/test/test_nrf_radio_priority_integration.py index 6af06539e..a2e133edf 100644 --- a/test/test_nrf_radio_priority_integration.py +++ b/test/test_nrf_radio_priority_integration.py @@ -14,6 +14,12 @@ class NrfRadioPriorityIntegrationTests(unittest.TestCase): compile_run=timing.ProfileSwitchTimingTest.compile_run def test_real_transaction_includes_entry_cost_and_releases_before_stats_or_rollback(self): + self.exercise_real_transaction('MC_NRF_RADIO_PROFILE_PRIORITY', 'radiopriority', 'Lease') + + def test_usb_irq_guard_uses_same_measured_endpoint_and_failure_release_contract(self): + self.exercise_real_transaction('MC_NRF_RADIO_PROFILE_USB_IRQ_GUARD', 'radiousb', 'Guard') + + def exercise_real_transaction(self, flag, namespace, guard_type): source=(ROOT/"src/helpers/radiolib/RadioLibWrappers.cpp").read_text() methods="\n".join(method(source,signature) for signature in ( "uint8_t RadioLibWrapper::beginReconfigure(", @@ -38,11 +44,11 @@ static uint32_t sequence_at_start=0; lease=r''' #include @GLOBALS@ -namespace mesh { namespace radiopriority { -class Lease { +namespace mesh { namespace @NAMESPACE@ { +class @GUARD_TYPE@ { bool admitted=false; public: - explicit Lease(bool owned) { + explicit @GUARD_TYPE@(bool owned) { assert(owned==expected_owned); if(!owned) return; assert(!priority_active); @@ -57,14 +63,14 @@ class Lease { admitted=priority_active=false;++releases; elapsed_us+=1000; // Model immediate USB scheduling during restoration. } - ~Lease() { release(); } + ~@GUARD_TYPE@() { release(); } }; } } -'''.replace("@GLOBALS@",globals_source) +'''.replace("@GLOBALS@",globals_source).replace('@NAMESPACE@',namespace).replace('@GUARD_TYPE@',guard_type) fixture=fixture.replace("@METHODS@",lease+methods) fixture=r'''#define MESH_HIL_PROFILE_TIMING 1 -#define MC_NRF_RADIO_PROFILE_PRIORITY 1 -'''+fixture+r''' +#define @FLAG@ 1 +'''.replace('@FLAG@',flag)+fixture+r''' int main() { using Result=mesh::RadioParamApplyResult; for(unsigned scenario=0;scenario<8;++scenario) { diff --git a/test/test_nrf_radio_usb_irq_guard.py b/test/test_nrf_radio_usb_irq_guard.py new file mode 100644 index 000000000..8ac95d35f --- /dev/null +++ b/test/test_nrf_radio_usb_irq_guard.py @@ -0,0 +1,306 @@ +"""Execute the HIL-only USB IRQ guard, including its independent deadline ISR.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_rak3401_buffered_radio_hal import MOCK_HAL + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / 'src/helpers/radiolib/NrfRadioUsbIrqGuard.cpp' + +ARDUINO = r''' +#pragma once +#include +#include +struct TaskMock { unsigned priority; const char* name; }; +using TaskHandle_t=TaskMock*; +enum { TASK_PRIO_LOW=1 }; +enum IRQn_Type { GPIOTE_IRQn=6,RTC2_IRQn=36,USBD_IRQn=39 }; +struct RtcMock { + uint32_t COUNTER=0,PRESCALER=0,INTENSET=0,EVTEN=0; + uint32_t EVENTS_COMPARE[4]{},CC[4]{}; +}; +extern RtcMock rtc; +#define NRF_RTC2 (&rtc) +struct NvicState { unsigned __cr_flag=0; }; +extern NvicState nrf_nvic_state; +extern TaskMock loopTask,otherTask; +extern TaskHandle_t currentTask; +extern unsigned primask,basepri,faultmask; +extern bool inIsr,irqEnabled[64],irqPending[64],fireBeforeUsbMask; +extern unsigned irqPriority[64],nvicChanges,sdkCalls,sdkFailures,taskYields; +extern int failOperation; +extern bool failTransfer; +extern "C" void RTC2_IRQHandler(); +void expire(); +inline bool isInISR() { return inIsr; } +inline TaskHandle_t xTaskGetCurrentTaskHandle() { return currentTask; } +inline const char* pcTaskGetName(TaskHandle_t task) { return task->name; } +inline unsigned uxTaskPriorityGet(TaskHandle_t task) { return task->priority; } +inline unsigned __get_PRIMASK() { return primask; } +inline unsigned __get_BASEPRI() { return basepri; } +inline unsigned __get_FAULTMASK() { return faultmask; } +inline void taskYIELD() { ++taskYields; } +inline bool NVIC_GetEnableIRQ(IRQn_Type irq) { return irqEnabled[irq]; } +inline bool NVIC_GetPendingIRQ(IRQn_Type irq) { return irqPending[irq]; } +inline unsigned NVIC_GetPriority(IRQn_Type irq) { return irqPriority[irq]; } +inline void permitted(IRQn_Type irq) { + assert(irq==USBD_IRQn || irq==RTC2_IRQn);++nvicChanges; +} +inline void NVIC_SetPriority(IRQn_Type irq,unsigned priority) { + permitted(irq);irqPriority[irq]=priority; +} +inline void NVIC_EnableIRQ(IRQn_Type irq) { permitted(irq);irqEnabled[irq]=true; } +inline void NVIC_DisableIRQ(IRQn_Type irq) { + permitted(irq); + if(irq==USBD_IRQn && fireBeforeUsbMask) { fireBeforeUsbMask=false;expire(); } + irqEnabled[irq]=false; +} +inline void NVIC_ClearPendingIRQ(IRQn_Type irq) { permitted(irq);irqPending[irq]=false; } +''' + +SDM = r''' +#pragma once +#include +constexpr unsigned NRF_SUCCESS=0; +extern unsigned sdkQueryResult,sdkQueryEnabled,sdkQueries; +inline unsigned sd_softdevice_is_enabled(uint8_t* enabled) { + ++sdkQueries;*enabled=sdkQueryEnabled;return sdkQueryResult; +} +''' + +NVIC = r''' +#pragma once +#include +inline bool sdkFail(int operation) { + ++sdkCalls; + if(operation==failOperation) { failOperation=0;++sdkFailures;return true; } + return false; +} +inline bool legal(unsigned priority) { return priority<8 && priority!=0 && priority!=1 && priority!=4; } +inline unsigned sd_nvic_SetPriority(IRQn_Type irq,unsigned priority) { + if(sdkFail(1)) return 91; + assert(legal(priority));NVIC_SetPriority(irq,priority);return 0; +} +inline unsigned sd_nvic_EnableIRQ(IRQn_Type irq) { + if(sdkFail(2)) return 92; + assert(legal(irqPriority[irq]));NVIC_EnableIRQ(irq);return 0; +} +inline unsigned sd_nvic_DisableIRQ(IRQn_Type irq) { + if(sdkFail(3)) return 93; + NVIC_DisableIRQ(irq);return 0; +} +inline unsigned sd_nvic_ClearPendingIRQ(IRQn_Type irq) { + if(sdkFail(4)) return 94; + NVIC_ClearPendingIRQ(irq);return 0; +} +''' + +RTC = r''' +#pragma once +#include +constexpr unsigned RTC_INTENSET_COMPARE0_Msk=1u<<16; +enum nrf_rtc_event_t { NRF_RTC_EVENT_COMPARE_0=0 }; +inline void nrf_rtc_int_enable(RtcMock* p,unsigned mask) { p->INTENSET|=mask; } +inline void nrf_rtc_int_disable(RtcMock* p,unsigned mask) { p->INTENSET&=~mask; } +inline void nrf_rtc_event_clear(RtcMock* p,nrf_rtc_event_t) { p->EVENTS_COMPARE[0]=0; } +inline bool nrf_rtc_event_check(RtcMock* p,nrf_rtc_event_t) { return p->EVENTS_COMPARE[0]!=0; } +inline void nrf_rtc_cc_set(RtcMock* p,unsigned ch,unsigned value) { p->CC[ch]=value; } +inline unsigned nrf_rtc_cc_get(RtcMock* p,unsigned ch) { return p->CC[ch]; } +''' + +MOCK = MOCK_HAL.replace('#include ', '#include \n#include ') +MOCK = MOCK.replace(' ++bursts;', ''' + if(failTransfer) { expire();assert(irqEnabled[USBD_IRQn]); } + ++bursts;''') +MOCK = MOCK.replace('virtual ~ArduinoHal() = default;', r''' + unsigned yields=0,transactions=0; + bool transactionUsb=false; + virtual ~ArduinoHal() = default; + virtual void yield() { assert(irqEnabled[USBD_IRQn]);++yields; } + virtual void spiBeginTransaction() { ++transactions;transactionUsb=irqEnabled[USBD_IRQn]; } +''') + +FIXTURE = r''' +#include +#include +RtcMock rtc; +TaskMock loopTask{1,"loop"},otherTask{3,"usbd"}; +TaskHandle_t currentTask=&loopTask; +NvicState nrf_nvic_state; +unsigned primask=0,basepri=0,faultmask=0; +bool inIsr=false,irqEnabled[64]{},irqPending[64]{},fireBeforeUsbMask=false; +unsigned irqPriority[64]{},nvicChanges=0,sdkCalls=0,sdkFailures=0,taskYields=0; +unsigned sdkQueryResult=0,sdkQueryEnabled=0,sdkQueries=0; +int failOperation=0; +bool failTransfer=false; +void expire() { + rtc.COUNTER=rtc.CC[0];rtc.EVENTS_COMPARE[0]=1;irqPending[RTC2_IRQn]=true; + assert(irqEnabled[RTC2_IRQn]); + const bool was=inIsr;inIsr=true;RTC2_IRQHandler();inIsr=was; +} +void resetHardware(bool sd=false) { + using namespace mesh::radiousb; + assert(!work.active.load()); + rtc=RtcMock{};rtc.COUNTER=100;rtc.CC[0]=9876; + primask=basepri=faultmask=0;inIsr=false;nrf_nvic_state.__cr_flag=0; + currentTask=&loopTask;loopTask.priority=1; + sdkQueryResult=0;sdkQueryEnabled=sd;sdkQueries=0;failOperation=0; + for(unsigned i=0;i<64;++i) { irqEnabled[i]=false;irqPending[i]=false;irqPriority[i]=0; } + irqEnabled[USBD_IRQn]=true;irqPriority[USBD_IRQn]=6; + irqEnabled[GPIOTE_IRQn]=true;irqPriority[GPIOTE_IRQn]=6; + if(sd) irqPriority[RTC2_IRQn]=7; + nvicChanges=sdkCalls=sdkFailures=taskYields=0; +} +void restoredHardware(bool sd=false) { + using namespace mesh::radiousb; + assert(!work.active.load() && mesh_hil_radio_usb_irq.active==0); + assert(irqEnabled[USBD_IRQn] && !irqEnabled[RTC2_IRQn]); + assert(irqPriority[RTC2_IRQn]==unsigned(sd?7:0) && irqPriority[USBD_IRQn]==6); + assert(!irqPending[RTC2_IRQn] && rtc.INTENSET==0 && rtc.EVTEN==0 && rtc.CC[0]==9876); + assert(rtc.PRESCALER==0 && rtc.EVENTS_COMPARE[0]==0); + assert(irqEnabled[GPIOTE_IRQn] && irqPriority[GPIOTE_IRQn]==6); + assert(primask==0 && basepri==0 && faultmask==0); +} +int earlyFailure() { mesh::radiousb::Guard guard(true);assert(!irqEnabled[USBD_IRQn]);return -1; } +int main() { + using namespace mesh::radiousb; + auto& capture=mesh_hil_radio_usb_irq; + assert(capture.magic==0x4d435255 && capture.version==1 && capture.size_bytes==96); + SPIClass bus;Nrf52BufferedRadioHal concrete(bus,8000000);ArduinoHal& hal=concrete; + // All admission exclusions make no NVIC writes and preserve other owners. + for(unsigned gate=0;gate<18;++gate) { + resetHardware();bool owned=true; + switch(gate) { + case 0: owned=false;break; + case 1: inIsr=true;break; + case 2: currentTask=&otherTask;break; + case 3: loopTask.priority=3;break; + case 4: primask=1;break; + case 5: basepri=1;break; + case 6: faultmask=1;break; + case 7: nrf_nvic_state.__cr_flag=1;break; + case 8: irqEnabled[USBD_IRQn]=false;break; + case 9: irqEnabled[RTC2_IRQn]=true;break; + case 10: irqPending[RTC2_IRQn]=true;break; + case 11: rtc.INTENSET=1;break; + case 12: rtc.EVTEN=1u<<17;break; + case 13: rtc.EVENTS_COMPARE[1]=1;break; + case 14: rtc.PRESCALER=31;break; + case 15: sdkQueryResult=123;break; + case 16: sdkQueryEnabled=1;irqPriority[RTC2_IRQn]=0;break; + case 17: sdkQueryEnabled=1;irqPriority[USBD_IRQn]=4;break; + } + { Guard guard(owned);assert(!work.active.load()); } + assert(nvicChanges==0); + } + resetHardware(); + irqPending[USBD_IRQn]=true; + { Guard outer(true); + assert(work.active.load() && !irqEnabled[USBD_IRQn] && irqEnabled[RTC2_IRQn]); + assert(irqPending[USBD_IRQn] && irqPriority[RTC2_IRQn]==2 && rtc.CC[0]==132); + { Guard inner(true); }assert(work.active.load() && !irqEnabled[USBD_IRQn]); + const unsigned before=hal.yields; + hal.yield();assert(hal.yields==before && taskYields==1); + hal.spiBeginTransaction();assert(!hal.transactionUsb); + outer.release();outer.release(); + assert(irqPending[USBD_IRQn]); + } + restoredHardware();assert(sdkCalls==0 && sdkQueries==1); + // The timer ISR restores USB without another HAL poll/frame returning. + resetHardware(); + { Guard guard(true);expire();restoredHardware(); } + restoredHardware(); + // Real buffered transfer can trigger the simulated deadline independently. + resetHardware();failTransfer=true; + { Guard guard(true);uint8_t tx[3]={0x86,1,2},rx[3]{};hal.spiTransfer(tx,3,rx); + assert(rx[0]==uint8_t(tx[0]^0xa5) && bus.bursts==1); + } + failTransfer=false;restoredHardware(); + // Cooperative checks also restore before CS or normal TinyUSB flush. + for(bool frame:{false,true}) { + resetHardware();rtc.COUNTER=0xfffff0; + { Guard guard(true);assert(rtc.CC[0]==0x10);rtc.COUNTER=0x10; + if(frame) { hal.spiBeginTransaction();assert(hal.transactionUsb); } + else { const unsigned before=hal.yields;hal.yield();assert(hal.yields==before+1); } + } + restoredHardware(); + } + // Expiry racing immediately before USB masking cannot leave USB disabled. + resetHardware();fireBeforeUsbMask=true; + { Guard guard(true);restoredHardware(); }restoredHardware(); + // An unrelated/spurious timer IRQ does not clear another source/event. + resetHardware();rtc.EVENTS_COMPARE[2]=1;RTC2_IRQHandler(); + assert(rtc.EVENTS_COMPARE[2]==1 && nvicChanges==0); + resetHardware();assert(earlyFailure()==-1);restoredHardware(); + // A separate USB owner cannot silently invalidate the isolation experiment. + resetHardware(); + { Guard guard(true);const unsigned before=capture.usb_reenabled; + irqEnabled[USBD_IRQn]=true;hal.yield(); + assert(capture.usb_reenabled==before+1);restoredHardware(); + } + // SD-on path uses checked inline app-NVIC helpers, including deadline ISR. + resetHardware(true); + { Guard guard(true);assert(capture.last_sdk_enabled==1 && capture.last_sdk_result==0); + expire();restoredHardware(true); } + assert(sdkCalls>0 && sdkFailures==0 && sdkQueries==1); + // Fail each admission API before USB can remain masked; restore exact state. + for(int operation:{1,2,3}) { + resetHardware(true);failOperation=operation; + { Guard guard(true); }restoredHardware(true); + assert(sdkFailures==1); + } + resetHardware(); + const unsigned before=hal.yields; + for(unsigned i=0;i<1000;++i) hal.yield(); + assert(hal.yields==before+1000 && taskYields==0 && nvicChanges==0); + assert(capture.entries==capture.releases && capture.active==0 && capture.expired>=5); +} +''' + + +class NrfRadioUsbIrqGuardTests(unittest.TestCase): + def test_real_guard_hal_and_deadline_isr_preserve_irq_ownership(self): + with tempfile.TemporaryDirectory() as directory: + folder=Path(directory) + (folder/'hal/Arduino').mkdir(parents=True) + for name,content in (('Arduino.h',ARDUINO),('nrf_sdm.h',SDM), + ('nrf_nvic.h',NVIC),('nrf_rtc.h',RTC), + ('hal/Arduino/ArduinoHal.h',MOCK),('test.cpp',FIXTURE)): + (folder/name).write_text(content,encoding='ascii') + binary=folder/'test.exe' + built=subprocess.run([os.environ.get('CXX','g++'),'-std=c++17','-Wall', + '-Wextra','-Werror','-DNRF52_PLATFORM','-DMESH_HIL_PROFILE_TIMING=1', + '-DMC_NRF_RADIO_PROFILE_USB_IRQ_GUARD=1','-I',str(folder),'-I',str(ROOT/'src'), + str(folder/'test.cpp'),str(SOURCE),'-o',str(binary)],capture_output=True,text=True, + env={**os.environ,'TMPDIR':str(folder)}) + self.assertEqual(built.returncode,0,built.stderr) + ran=subprocess.run([str(binary)],capture_output=True,text=True) + self.assertEqual(ran.returncode,0,ran.stderr) + + def test_default_off_has_no_code_types_ram_or_sdk_dependencies(self): + for source in ('#include \n',SOURCE.read_text()): + result=subprocess.run([os.environ.get('CXX','g++'),'-E','-P','-x','c++', + '-I',str(ROOT/'src'),'-I',str(SOURCE.parent),'-'],input=source, + capture_output=True,text=True) + self.assertEqual(result.returncode,0,result.stderr) + self.assertFalse(result.stdout.strip()) + + def test_experiment_requires_hil_nrf_and_separate_priority_measurement(self): + for flags,diagnostic in (([], 'requires nRF52 HIL profile timing'), + (['-DNRF52_PLATFORM'],'requires nRF52 HIL profile timing'), + (['-DMESH_HIL_PROFILE_TIMING=1'],'requires nRF52 HIL profile timing'), + (['-DNRF52_PLATFORM','-DMESH_HIL_PROFILE_TIMING=1', + '-DMC_NRF_RADIO_PROFILE_PRIORITY=1'],'experiments separately')): + result=subprocess.run([os.environ.get('CXX','g++'),'-E','-x','c++', + '-DMC_NRF_RADIO_PROFILE_USB_IRQ_GUARD=1',*flags,'-I',str(ROOT/'src'),'-'], + input='#include \n', + capture_output=True,text=True) + self.assertNotEqual(result.returncode,0) + self.assertIn(diagnostic,result.stderr) + + +if __name__=='__main__': + unittest.main()