Add bounded USB IRQ isolation experiment for nRF radio retunes

This commit is contained in:
mikecarper committed 2026-10-23 03:05:18 -07:00
1 parent c83d43e03c
commit bb9431973a
10 files changed
+627 -12

No files matched your search

+2
View File
@@ -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
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -24,7 +24,8 @@ protected:
void endProfileRetune(bool success) override {
static_cast<CustomSX1262*>(_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<const CustomSX1262*>(_radio)->hilOwnsWarmProfileRetune();
}
+21 -2
View File
@@ -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;
@@ -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
+249
View File
@@ -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 <Arduino.h>
#include <atomic>
#include <stdint.h>
#include <string.h>
#include <nrf_nvic.h>
#include <nrf_rtc.h>
#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<uint32_t> active{0};
std::atomic<uint32_t> 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
+14
View File
@@ -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
+2 -1
View File
@@ -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.
+13 -7
View File
@@ -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 <helpers/radiolib/ProfileSwitchTiming.h>
@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) {
+306
View File
@@ -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 <cassert>
#include <cstdint>
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 <Arduino.h>
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 <nrf_sdm.h>
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 <Arduino.h>
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 <cassert>', '#include <Arduino.h>\n#include <cassert>')
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 <initializer_list>
#include <helpers/radiolib/Nrf52BufferedRadioHal.h>
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 <helpers/radiolib/NrfRadioUsbIrqGuard.h>\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 <helpers/radiolib/NrfRadioUsbIrqGuard.h>\n',
capture_output=True,text=True)
self.assertNotEqual(result.returncode,0)
self.assertIn(diagnostic,result.stderr)
if __name__=='__main__':
unittest.main()