RP2350 + W5500 + USB-NCM Reusable Snippets
RP2350 + W5500 + USB-NCM Reusable Snippets
Introduction
Getting a microcontroller to speak both wired Ethernet and USB networking simultaneously sounds straightforward — until the DHCP client wedges itself at boot, lwIP deadlocks in an interrupt, and the USB host sees a network interface that silently drops every packet. These are the kinds of bugs that don't show up in any tutorial because they only surface in the specific combination of hardware and software you happen to be running.
That's exactly the experience documented in this GitHub Gist by yasunorioi: three reusable C++ header files extracted from a real project — ccm_rp2350_relay v1.3.2 — a UECS-CCM relay node built on the Raspberry Pi RP2350B microcontroller, WIZnet W5500 Ethernet chip, and USB CDC-NCM (Network Control Model), running on the arduino-pico stack with Adafruit_TinyUSB and lwIP. The patterns are released under 0BSD (public domain) so anyone can drop them directly into their own project.
The Hardware: RP2350B + WIZnet W5500
The project runs on a Waveshare RP2350 8DI/8RO board, which pairs Raspberry Pi's RP2350B microcontroller with a WIZnet W5500 Ethernet controller over SPI0.
The W5500 is WIZnet's flagship hardwired TCP/IP chip. Unlike software TCP/IP stacks that consume MCU cycles, the W5500 offloads the full TCP/IP, UDP, ICMP, IPv4, ARP, IGMP, and PPPoE protocol stack into hardware, exposing up to eight independent socket channels to the host over a high-speed SPI interface (up to 80 MHz). It contains 32 KB of internal TX/RX buffer memory and a built-in 10/100Base-T PHY, meaning no external PHY chip is needed. The net result is that an RP2350 can handle Ethernet traffic without dedicating significant CPU cycles to packet framing or protocol processing.
The RP2350B (the "B" variant) brings dual Arm Cortex-M33 cores, 520 KB of SRAM, 48 GPIO pins, and hardware support for floating-point and DSP — making it substantially more capable than its RP2040 predecessor while remaining cost-effective for embedded applications.
The combination — RP2350B running application code, W5500 handling the Ethernet stack in hardware, and TinyUSB providing the USB device stack — creates a device that can act as a network relay: Ethernet packets come in over the W5500, get processed by application logic, and exit over USB as a CDC-NCM virtual network interface (or vice versa). This is the architecture of ccm_rp2350_relay.
The Problem Space: Three Foot-Guns
The author describes these snippets as the result of "a couple of debugging weekends." Each of the three headers solves a distinct failure mode that emerges from the interaction between the W5500 driver, lwIP, and TinyUSB in the arduino-pico environment. None of these bugs are obvious, and none are clearly documented anywhere in the existing literature.
File 1: EthLinkWatcher.h — DHCP Recovery on Late Cable Insertion
The Problem
LwipIntfDev::begin() (the arduino-pico Ethernet base class) calls dhcp_start() exactly once at startup and never monitors the physical link state after that. If the LAN cable is unplugged when the board boots — a completely normal scenario for any device that might be powered on before being cabled — the DHCP client dutifully sends DISCOVERs into the dead W5500 PHY. The W5500's internal socket state gets wedged in this process.
When you later plug the cable in, the "obvious" recovery sequence (netif_set_link_up, dhcp_release_and_stop, dhcp_start) fires without error — but no DHCP OFFER ever arrives. The W5500 socket is stuck and won't recover through the lighter API calls.
The Fix
The only reliable recovery is a full stack teardown and rebuild: eth.end() (which closes the W5500 socket, removes the lwIP netif, and stops DHCP) followed by eth.begin() (which reopens the W5500 in MACRAW mode and fires a fresh dhcp_start()). Critically, because the W5500 Ethernet interface and the USB-NCM interface are separate entries on lwIP's netif_list, tearing down the Ethernet netif doesn't disturb the USB network interface at all.
EthLinkWatcher is a lightweight C++ template that wraps any LwipIntfDev<RawDev> driver — W5500, ENC28J60, LAN8720, or any other arduino-pico Ethernet driver — and polls linkStatus() on a configurable interval (default 2 seconds). On a LinkOFF → LinkON edge, it performs the full end()/begin() cycle. An optional onReinit() callback lets the application re-bind sockets, restart mDNS, or take any other post-recovery action.
// Usage Wiznet5500lwIP eth(...); EthLinkWatcher<Wiznet5500lwIP> ethWatcher(eth); // file-scope void loop() { ethWatcher.poll(); // ... } // Optional: re-bind services after recovery ethWatcher.onReinit([] { mdnsServer.restart(); });The design is deliberately minimal — poll() returns immediately if the interval hasn't elapsed or the link state hasn't changed, so it adds essentially zero overhead to loop() when nothing is happening.
File 2: LwipRxStager.h — Safe IRQ-to-Main Packet Handoff
The Problem
On arduino-pico without FreeRTOS, both tud_task() (the TinyUSB task) and the W5500 packet handler run in soft-IRQ context. This means raw Ethernet RX callbacks — including tud_network_recv_cb() for CDC-NCM — fire from an interrupt handler.
The natural thing to do in such a callback is allocate a pbuf and pass the frame to lwIP immediately:
bool tud_network_recv_cb(const uint8_t *src, uint16_t size) { struct pbuf *p = pbuf_alloc(PBUF_RAW, size, PBUF_POOL); // dangerous! // ... }This fails in two distinct and confusing ways:
PBUF_POOL allocation is fast but draws from a fixed pool of pre-allocated pbufs. On a device also running mDNS (which generates its own announcements), the 24-entry default pool gets exhausted. pbuf_alloc returns NULL, the frame is silently dropped, and from the host's perspective the device's IP never resolves — because the ARP reply gets dropped. This manifests as the device appearing unreachable despite the USB netdev showing as UP with carrier.
PBUF_RAM allocation goes through mem_malloc(), which acquires lwIP's internal heap mutex. If anything on the main thread is also allocating lwIP memory at that moment, the IRQ-context allocation races against it and deadlocks. In practice, setup() hangs.
The Fix
Don't allocate in the IRQ at all. Instead, memcpy the raw frame bytes into a static staging buffer in the IRQ handler, set a volatile length flag, and let loop() perform the pbuf_alloc and ethernet_input() call safely from main-thread context.
LwipRxStager is a single-slot version of this pattern:
enqueueFromIRQ()— called from the IRQ callback. Copies the frame into the internal buffer and sets_len. Returnsfalse(backpressure) if the previous frame hasn't been drained yet; TinyUSB and similar drivers interpretfalseas a signal to retry delivery.drainOnce()— called fromloop(). TriesPBUF_POOLfirst (cheap), falls back toPBUF_RAMif the pool is empty, callsethernet_input(), and clears_len.reset()— clears the staging slot, useful when USB re-enumerates to avoid replaying a half-staged frame.
The author notes this is a single-slot design appropriate for management-plane traffic (ARP, DHCP, HTTP) rather than high-throughput data paths. For bursty traffic, a ring of N slots with proper atomic memory ordering across cores would be needed.
File 3: Adafruit_USBD_NCM.h — Correct CDC-NCM Composite Descriptor
The Problem
CDC-NCM (Network Control Model) is a USB class for carrying raw Ethernet frames over USB, making a device appear as a network interface to the host operating system — no driver installation required on Linux or Windows 11. It's the right choice for a network relay: clean, standard, and well-supported.
The non-obvious problem is that NCM is a two-interface CDC function: one control interface (CDC subclass 0x0D) and one data interface (CDC-Data class 0x0A, with two alternate settings). When building a composite USB descriptor with Adafruit_TinyUSB, you must call TinyUSBDevice.allocInterface(2) inside your getInterfaceDescriptor() implementation — allocating two interface numbers, not one.
Every existing example and comment in the wild uses allocInterface(1). With that call, bNumInterfaces in the configuration descriptor ends up one short. The host's USB stack processes the descriptor, the netdev appears with LOWER_UP and carrier on Linux, but the bulk IN/OUT endpoints on the data interface are never actually bound to a driver. No packets ever flow — not because of any protocol error, but because the host never finishes setting up the interface. This is an extremely difficult bug to diagnose because the device appears fully connected from both sides.
The Fix
The fix is a single character change: allocInterface(2) instead of allocInterface(1). The Adafruit_USBD_NCM header wraps this correctly in a self-contained class that handles the composite descriptor, MAC address string descriptor registration, and endpoint allocation — exposing a minimal begin() / getInterfaceDescriptor() interface that follows the standard Adafruit_TinyUSB pattern.
// Usage uint8_t tud_network_mac_address[6] = {0x02, 0x02, 0x84, 0x6A, 0x96, 0x00}; Adafruit_USBD_NCM usb_ncm; void setup() { SerialTinyUSB.begin(115200); usb_ncm.begin(); TinyUSB_Port_InitDevice(0); // Then bring up a lwIP netif and register // tud_network_recv_cb / tud_network_xmit_cb yourself. }Architecture of the Full System
Together, the three headers enable a clean two-interface networking device on a single RP2350B:
[ LAN Cable ] ──► W5500 (SPI0) ──► lwIP netif #1 (Wiznet5500lwIP)
│
EthLinkWatcher polls, recovers DHCP
│
Application logic (UECS-CCM relay)
│
[ USB Host ] ──► TinyUSB NCM ──► lwIP netif #2 (USB-NCM)
▲
LwipRxStager buffers IRQ frames
Adafruit_USBD_NCM fixes descriptorPackets flow in either direction through application logic, with lwIP routing between the two netifs. The W5500 handles all the Ethernet TCP/IP state in hardware; the USB-NCM interface presents as a standard CDC Ethernet device to the connected host.
Why This Matters for Developers
Each of these three problems represents a category of bug that is genuinely difficult to find:
The DHCP wedge bug is a boot-ordering issue that only appears when the cable is absent at power-on — a condition that often doesn't happen during development but happens constantly in deployment. The fix (full stack teardown) is counterintuitive because the lighter DHCP restart APIs seem like they should work.
The IRQ pbuf allocation bug is a concurrency issue that manifests as silent packet loss or a hanging setup() — two very different symptoms from the same root cause. The connection between mDNS pool exhaustion and dropped ARP replies is not at all obvious without understanding lwIP's internal memory model.
The NCM interface count bug is a USB descriptor correctness issue that produces a perfectly-functioning-looking broken state: the netdev is UP with carrier, but nothing works. The correct value (allocInterface(2)) is not documented in any TinyUSB NCM example and contradicts what every existing snippet shows.
By extracting and documenting these patterns cleanly, the gist saves any developer targeting the RP2350 + W5500 + TinyUSB stack from spending the same debugging weekends. All three headers are generic — EthLinkWatcher works with any arduino-pico Ethernet driver, LwipRxStager works with any lwIP raw-RX callback, and Adafruit_USBD_NCM works on any Adafruit_TinyUSB-supported platform (RP2040, RP2350, ESP32, and others). The 0BSD license means there are no attribution requirements — copy them in and move on.
WIZnet W5500: The Right Chip for This Use Case
The choice of the W5500 for this project is worth highlighting. A relay node that bridges Ethernet to USB has an unusual requirement profile: it needs to move frames reliably between two network interfaces with minimal CPU involvement, since the RP2350B's cycles are also needed for application logic (in this case, UECS-CCM protocol processing).
The W5500's hardwired TCP/IP stack is a natural fit: the chip handles socket management, ARP, DHCP, and protocol framing autonomously, leaving the MCU to focus on the relay logic. The 32 KB internal buffer accommodates bursts of traffic without requiring the application to service the SPI interface continuously. The SPI interface itself (up to 80 MHz, with variable-length data frames) integrates cleanly with arduino-pico's Wiznet5500lwIP driver, which presents the W5500 as a standard lwIP netif.
The W5500 is also one of the few Ethernet chips with mature, well-maintained support in the arduino-pico ecosystem, which reduces the surface area for additional driver-level bugs on top of the application-level ones this gist already addresses.
Getting Started
The gist contains three header-only files, usable independently:
EthLinkWatcher.h— Drop into any project usingLwipIntfDev-based Ethernet on arduino-picoLwipRxStager.h— Drop into any project callingethernet_input()from a TinyUSB or other IRQ callbackAdafruit_USBD_NCM.h— Drop into any composite TinyUSB device that needs CDC-NCM
Prerequisites: arduino-pico 4.5+, Adafruit_TinyUSB, lwIP via pico-sdk, -DUSE_TINYUSB=1, -DCFG_TUD_NCM=1.
Tested on: Waveshare RP2350 8DI/8RO board, Linux host (NetworkManager), Windows 11 host.
Conclusion
ccm_rp2350_relay and the snippets extracted from it represent exactly the kind of practical embedded systems knowledge that rarely makes it into documentation: the bugs that only appear in the intersection of specific hardware, a specific software stack, and specific runtime conditions. The RP2350B + W5500 combination is an increasingly capable and cost-effective platform for networked embedded applications, and these three patterns — DHCP recovery, safe IRQ packet handoff, and correct CDC-NCM descriptor construction — will be relevant to anyone building on it with TinyUSB and lwIP.
The gist is at: gist.github.com/yasunorioi/466216e056265ef6f90b78ee5f7b042d

