Is there a better pattern than "log after, not during" for USB-only debugging?

I'm working with an Adafruit RP2040 Feather ThinkInk in Rust to drive some e-paper panels. The catch with this board is that there is zero hardware debug access: no SWD header, no footprints, and not even underside pads because the 24-pin FPC latch and display circuitry take up all that space. USB is my only way in or out, meaning defmt-rtt is out of the picture and I'm stuck using defmt-bbq and usbd-serial over USB CDC.

The issue is that I have two constraints that absolutely hate each other:

  • usb_dev.poll() has to be called every few milliseconds, or the host drops the connection.
  • A panel refresh completely blocks the CPU for anywhere from 2 to 20 seconds, with nothing pumping that poll loop.

On top of that, defmt-bbq drops buffered frames if the device isn't configured instead of queuing them, so anything logged before host attachment is lost.

My current workaround is to basically invert the whole process. I run the panel silently, recording timings and events into a fixed-size Report struct. Once the work is done, I bring the USB stack up, wait for UsbDeviceState::Configured, dump everything over serial at once, and then just park in a poll loop forever. You can see the code here: https://github.com/melastmohican/adafruit-feather-thinkink-discovery, specifically in src/usb_report.rs.

It's reliable and handles all eight of my examples without duplicating code, but the trade-off sucks. Because the serial port only enumerates after the run completes, there is zero live progress. If the panel hangs mid-refresh, I get absolutely nothing, which is exactly when I actually need the logs.

I haven't tried a few options yet:

  • Pumping poll() from a TIMER interrupt.
  • Passing a custom DelayNs implementation into the driver's busy-wait that services USB as a side effect (the driver takes a &mut DELAY there, so it should slot in).

Moving the whole thing to Embassy so the blocking refresh becomes a yield point.

Has anyone run into this kind of bottleneck before (long blocking work on a device where USB CDC is the only logging channel) and found a cleaner pattern? I'm especially curious if setting up the interrupt-driven poll is as straightforward as it sounds or if there's a hidden headache.

I've never faced a constraint like this, but I suggest you move the usb device driver to core1 (or move the elink driver to core1, same idea).

polling the driver in an interrupt handler is possible, but sharing states between the app and an ISR needs more care than share states between two threads.

I think you have multiple good options in the order of effort necessary:

  • poll() into an interrupt
  • move poll() to the second core
  • move to Embassy or RTIC

Your DelayNs idea sounds quite brittle. I would not do that.
That being said, depending on the (future) complexity of your firmware it might be worthwhile to move to Embassy or RTIC, because it makes the handling of concurrency way easier.
I suppose the panel driver is just "busy" waiting most of the time. So changing it to use async wait instead would free up the CPU almost completely.

If you decide to use interrupts you will either have to use unsafe and be careful not to use your usb stack elsewhere or use a mutex/critical section to do it safely.

For now, I moved logging to core1, and it seems to work reasonably well.