diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index e1866f233..4d5e5233d 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -815,6 +815,7 @@ jobs: python3 -B test/test_rak3401_buffered_radio_hal.py -v python3 -B test/test_sx126x_buffered_radio_hal.py -v python3 -B test/test_profile_switch_timing.py -v + python3 -B test/test_nrf_radio_yield_budget.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/Nrf52BufferedRadioHal.h b/src/helpers/radiolib/Nrf52BufferedRadioHal.h index e564b8f5f..be855c5e8 100644 --- a/src/helpers/radiolib/Nrf52BufferedRadioHal.h +++ b/src/helpers/radiolib/Nrf52BufferedRadioHal.h @@ -2,15 +2,44 @@ #include +#ifndef MC_NRF_RADIO_BUSY_SPIN_POLLS +#define MC_NRF_RADIO_BUSY_SPIN_POLLS 0 +#endif +#if MC_NRF_RADIO_BUSY_SPIN_POLLS < 0 || MC_NRF_RADIO_BUSY_SPIN_POLLS > 16 +#error "nRF radio yield experiment requires a bounded 0..16 poll budget" +#endif + // nRF52840 SPIClass::transfer(uint8_t) starts one SPIM transaction per byte. // RadioLib sends whole command buffers; keep its CS, BUSY, and status handling // but transfer each buffer in one SPIM transaction. LR1110 supports 16 MHz SPI. class Nrf52BufferedRadioHal : public ArduinoHal { +#if MC_NRF_RADIO_BUSY_SPIN_POLLS > 0 + uint8_t _busy_spin_polls_remaining = 0; +#endif public: explicit Nrf52BufferedRadioHal(SPIClass& bus, uint32_t hz = 16000000) : ArduinoHal(bus, SPISettings(hz, MSBFIRST, SPI_MODE0)) {} void spiTransfer(uint8_t* out, size_t len, uint8_t* in) override { - if (len) spi->transfer(out, in, len); + if (!len) return; + spi->transfer(out, in, len); +#if MC_NRF_RADIO_BUSY_SPIN_POLLS > 0 + _busy_spin_polls_remaining = MC_NRF_RADIO_BUSY_SPIN_POLLS; +#endif } + +#if MC_NRF_RADIO_BUSY_SPIN_POLLS > 0 + // 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 (_busy_spin_polls_remaining) { + --_busy_spin_polls_remaining; + return; + } + ArduinoHal::yield(); + } +#endif }; diff --git a/src/helpers/radiolib/ProfileSwitchTiming.h b/src/helpers/radiolib/ProfileSwitchTiming.h index 887af47f2..989000c0a 100644 --- a/src/helpers/radiolib/ProfileSwitchTiming.h +++ b/src/helpers/radiolib/ProfileSwitchTiming.h @@ -18,7 +18,7 @@ enum Stage : uint32_t { }; enum Result : uint32_t { Success = 0, Busy = 1, Failure = 2 }; static constexpr uint32_t Magic = 0x4d435054; // MCPT -static constexpr uint32_t Version = 1; +static constexpr uint32_t Version = 2; static constexpr uint32_t HistogramCount = 16; static constexpr uint32_t HistogramWidthUs = 64; #if defined(NRF52_PLATFORM) @@ -37,6 +37,7 @@ struct StageStats { struct DirectionStats { uint32_t successes, busy, failures, last_result, last_mask; uint32_t last_us[StageCount]; + uint32_t worst_us[StageCount]; // stages from the same worst successful hop StageStats stage[StageCount]; }; struct Capture { @@ -58,7 +59,13 @@ extern volatile Capture mesh_hil_profile_timing; extern Work mesh_hil_profile_timing_work; } -inline void barrier() { __asm__ __volatile__("" ::: "memory"); } +inline void barrier() { +#if defined(__arm__) || defined(__thumb__) + __asm__ __volatile__("dmb" ::: "memory"); +#else + __asm__ __volatile__("" ::: "memory"); +#endif +} // Call once during wrapper initialization, outside the measured switch. // The nRF core enables trace and CYCCNT only; it does not enable debug/halt. @@ -113,6 +120,8 @@ inline void finish(Result result, uint32_t now_ticks) { ++capture.sequence; barrier(); auto& direction = capture.direction[work.direction]; + const bool worst = result == Success && (direction.stage[Whole].count == 0 + || work.duration_us[Whole] > direction.stage[Whole].max_us); direction.last_result = result; direction.last_mask = work.mask; if (result == Success) ++direction.successes; @@ -121,6 +130,7 @@ inline void finish(Result result, uint32_t now_ticks) { for (uint32_t i = 0; i < StageCount; ++i) { const uint32_t duration = work.duration_us[i]; direction.last_us[i] = duration; + if (worst) direction.worst_us[i] = duration; if (result != Success || !(work.mask & (uint32_t(1) << i))) continue; auto& stats = direction.stage[i]; const uint32_t previous_low = stats.sum_low_us; @@ -137,7 +147,7 @@ inline void finish(Result result, uint32_t now_ticks) { ++capture.sequence; } -static_assert(sizeof(Capture) == 40 + 2 * (20 + StageCount * (4 + 88)), +static_assert(sizeof(Capture) == 40 + 2 * (20 + StageCount * (8 + 88)), "Unexpected profile timing ABI"); } // namespace profiletiming diff --git a/test/test_nrf_radio_yield_budget.py b/test/test_nrf_radio_yield_budget.py new file mode 100644 index 000000000..2ba8c1413 --- /dev/null +++ b/test/test_nrf_radio_yield_budget.py @@ -0,0 +1,110 @@ +"""Execute finite per-frame radio yield budgets and default-off behavior.""" +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] + +MOCK = MOCK_HAL.replace('virtual ~ArduinoHal() = default;', ''' + unsigned yields = 0; + virtual ~ArduinoHal() = default; + virtual void yield() { ++yields; } +''') + +FIXTURE = r''' +#include +int main() { + SPIClass bus; + Nrf52BufferedRadioHal concrete(bus,8000000); + ArduinoHal& radio=concrete; + constexpr unsigned budget=MC_NRF_RADIO_BUSY_SPIN_POLLS; +#if MC_NRF_RADIO_BUSY_SPIN_POLLS == 0 + static_assert(sizeof(Nrf52BufferedRadioHal)==sizeof(ArduinoHal),"default RAM growth"); +#endif + // The first pre-transfer wait delegates immediately, even in opt-in builds. + for(unsigned i=0;i<1000;++i) radio.yield(); + assert(radio.yields==1000 && bus.bursts==0); + radio.spiTransfer(nullptr,0,nullptr); + radio.yield(); + assert(radio.yields==1001 && bus.bursts==0); + + uint8_t tx[]={0x86,0x12,0x34},rx[3]={}; + for(unsigned frame=0;frame<1000;++frame) { + const unsigned before=radio.yields; + radio.spiTransfer(tx,sizeof(tx),rx); + assert(rx[0]==uint8_t(tx[0]^0xa5) && rx[2]==uint8_t(tx[2]^0xa5)); + for(unsigned i=0;i