At the end of October 2025, I got involved with the Bronco Space Lab at Cal Poly Pomona. They were building a CubeSat, the PROVES CubeSat Kit, to serve as a high-quality reference CubeSat to accelerate CubeSat programs at other universities. In addition to Cal Poly Pomona, the kit was in use at Texas State, UCSC, Columbia, and Harvard when I joined the lab.
A software rewrite was in-progress from CircuitPython software used on previous mission to a new version based on the F Prime flight software framework from JPL. People at the Pomona lab and at collaborator universities throughout the country, primarily Texas State University and Columbia University, were working furiously.
Fortunately, there was one significant component which no one had begun working on yet: a driver for the secondary radio aboard the CubeSat. That radio being a 2.4 GHz LoRa radio, using the SX1281 chip from Semtech, titled hereafter the "S band radio" as we called it in the lab. I picked it right up.
F Prime is a flight software framework with some very solid heritage, having powered JPL missions for years, including the recent Ingenuity mission which put a helicopter on Mars. As an aside, we were in fact lucky to have the guy in charge of the data pipeline for the Ingenuity mission advising our lab. F Prime is portable across operating systems, and even targets bare metal. To run on the ARM-based RP2350 aboard the flight controller board, we leveraged the Zephyr port of F Prime.
The S band radio module came up without any complications, excepting the minor hiccups coming from learning new software stacks. Although F Prime and Zephyr are each venerable and high-quality software, the F Prime port to Zephyr is quite young and had some kinks: methods made private which should have been public, unlogged errors, etc. With a couple hours here and there over a couple weeks, I got the radio to the point where I could put it in "Continuous Wave Mode" with a series of hard-coded command bytes and observe on an SDR that said wave was indeed produced, ending the "bring-up" phase.
Rather than writing a feature-complete driver from scratch, I integrated an
out-of-tree driver from the open source RadioLib project to save time.
Essentially RadioLib provides a thin HAL layer which needs to be implemented
on the platform, comprising GPIO read/write, delay, getting system time, etc.
After which point theoretically the high-level commands of the driver will
produce the right low-level HAL calls. One interesting problem I encountered
in this process was that, due to a bug in another component, system time was
not monotonic, causing timeouts to expire sooner than they should (as
(unsigned long) (current_time - start_time) > timeout evaluates true if
current_time goes behind start_time). I worked around this by using an
alternative time source.
Then, the driver was feature-complete. Below is the description of this
initial pull request I opened in
proves-core-reference
which goes into some detail into how and why the driver is designed as an F
Prime active component---in design pattern langauge, an "active object":
This pull request defines an
SBandcomponent which wraps the RadioLib driver for the SX1280 LoRa radio (hereafter the "S band radio"). RadioLib is a popular set of drivers for embedded-friendly radios and it has been added as a submodule inlib/RadioLib. The component implements theSvc.Cominterface and produces two telemetry channels:LastRssiandLastSnr.This pull request also adds an instance
sbandof theSBandcomponent to our main deployment. The driver interfaces with hardware via F Prime primitives, so the pull request instantiates these hardware dependencies (spiDriver,gpioSbandNrst,gpioSbandRxEn,gpioSbandTxEn,gpioSbandIRQ) and makes the requisite connections. Requisite changes to the devicetree have been made as well.A
ComCcsdsSbandsubtopology has also been defined and instantiated. It connects the S band radio to the master communication stack via the pre-existingComSplittercomponents.CircuitPython code defining a Uart-to-S-band-radio passthrough to validate the driver has been added. It depends on the PySquared flight software like the existing LoRa passthrough code and it can be found in
circuit-python-sband-passthrough).Finally, the name of the RFMx module has been fixed for the existing LoRa passthrough.
The
SBandcomponent uses a polling approach to detect incoming packets. An interrupt-driven design on the pattern of the existing LoRa radio was impossible because the IRQ lines for the S band radio are connected via the MCP23017 GPIO expander, whose interrupt lines are not connected to the MCU.The
SBandcomponent is an active component. This design decision was necessary because the polling handler must be very short to prevent rate-group slippage, but when a packet has arrived, expensive handling is required. WhenSBandis active, the expensive handling can be deferred off the rate group and onto the component’s own thread.To sidestep the need for complicated mutex code to prevent concurrent access to the SPI driver, all code touching the hardware runs inside async handlers that are called sequentially by the component’s thread. This includes the IRQ polling operation itself, ensuring that all SPI access is serialized.
These async ports are all internal and are triggered by synchronous external ports. This approach is more complicated than making the external ports async themselves, but it is necessary to check for and avoid queuing duplicate events. Otherwise the RX polling event, triggered at 10 Hz, could overflow the task queue when a heavier task like transmit or receive is running.
The radio was merged into our main git branch.
At this point, the driver worked, but it was quite slow. 2-seconds-per-256 byte-packet slow. And according to the LoRa calculator from Semtech, the SX1281 should have been capable of 200 kbps. Something was afoot.
From poking around at the RadioLib code as I was integrating it initially, I
already knew that it had many delay calls, some of which were pretty
generous. My hypothesis was that one of these was the cause.
Keeping that in mind, I did some quick-and-dirty profiling and saw that every high-level driver call took about 50 ms. Indeed, one of these overly-generous delays was slowing down the driver. And the delay was for exactly for 50ms. Configuring the driver differently (adding a connection for the BUSY pin, particularly) took RadioLib off of that slow path and reduced time per packet (for both send and receive) from 2s to 0.4s. Which was great, but the chip was capable of much better on paper. And because we hoped to use the greater data rate of the S band radio as compared to the primary radio for over-the-air updates, a faster data rate was desirable.
However, it turned out to not be possible, at least on the receive side, being of the most interest to us. The reason was that writes to flash of a single packet of the received firmware image took about 280 ms. And since we didn't have the RAM to cache the new firmware image, this meant that going much faster than 0.4s per packet was impossible to do from the perspective of the radio driver. The data rate was as good as it would get. Below is an image of that measurement from the logic analyzer.

Three days before handing our CubeSat off to the launch company, I realized that the radio had been dangerously broken on the main branch. Receiving a single packet caused the whole system to freeze. The bug was undetected as we had minimal automated testing. Demonstrating once again the importance of solid automatic HIL testing, for which we simply did not have the development resources in our short timeframe.
I found the breaking commit with a quick git bisect. I hoped it would be a
simple logic error, but the breaking commit only modified a seemingly unrelated
component. None of the driver logic had been touched.
Taking out the JTAG debugger, I saw that the problem was that local variables were being corrupted in a totally unrelated thread from the driver. Joy. Particularly, the vtable pointer of a local virtual object was null, triggering a null pointer exception. The exception and the debugger are pictured below. Note the time indicated on my laptop and the fact that handoff was January 5. That will be relevant soon.


I set a data watchpoint and saw that the local variable was being overwritten by a function call from another thread. The thread for my driver. The stack had overflowed onto the stack of the next thread. What had happened is the breaking commit had increased the stack usage of a function upon which my component relied past the allocated region. The below backtrace shows where the stack overflowed past its 4096 byte allocated region.

The solution would seem simple: increase the size of the region allocated for the stack of my component. However, we were at our RAM budget. We were pushing the RP2350 to its absolute limits, and there was no more RAM left to allocate to the S band radio driver component. This meant that the solution would actually have to be a more efficient use of existing RAM.
Knowing that there were not many opportunities in my code to reduce stack usage, and seeing as there was not much more than 24 hours remaining before handoff, I had to make the decision, alongside other team members, to remove the S band radio driver from the initial version of the satellite. Trying to pare down other parts of the code base in such a short time was just too risky. Seeing as the S band radio, a secondary radio, was one of the few non-essential software components, it had to be the thing on the chopping block when we were at RAM budget.
Fortunately, we have demonstrated that we can update the satellite remotely and we will hopefully be able to fit the driver back in the satellite and fly it later.
Please send comments to blogger-jack@pearson.onl.