Skip to content

Add USB NCM#3265

Open
functionpointer wants to merge 8 commits intoearlephilhower:masterfrom
functionpointer:usb_ncm_new
Open

Add USB NCM#3265
functionpointer wants to merge 8 commits intoearlephilhower:masterfrom
functionpointer:usb_ncm_new

Conversation

@functionpointer
Copy link
Copy Markdown
Contributor

@functionpointer functionpointer commented Dec 10, 2025

Overview

NCM (Network Control Model) is the newest of three protocols for Ethernet over USB.

This implementation creates an Ethernet connection with a host PC over USB without any extra hardware.
Other uses of USB like Serial, Mouse or Keyboard continue to work in parallel.

Use case is for smart home devices that live close to a PC or server. NCM gives the reliability of a wired connection without extra hardware like an Ethernet NIC and without the downsides of classic solutions like a custom protocol over serial. Instead, existing software like ESPHome can be used.

Testing

I have tested on Windows and Linux. No driver installation is required. MacOS should also work.
The example sketch provides 1ms ping latency.
grafik

Usage

Compile the provided example sketch.
Modify DHCP and logging as desired.

Once running, the Pico shows up as a new network interface on the host PC.
It must be configured before use. Example for Linux:

ip link set enp1s0u1u4i2 up
ip addr add 192.168.137.1/24 dev enp1s0u1u4i2 

On Windows, Internet Connection Sharing can be configured: https://superuser.com/a/1899168
That even includes a DHCP server. In my tests, it works with LWIP's built-in DHCP client.

@earlephilhower
Copy link
Copy Markdown
Owner

Very nice work! And thanks for the use case description.

Do you know what the delta RAM usage is for this on, say, Blink.ino? TinyUSB does everything statically allocated so I think enabling this will cost all applications some amount and want to understand how much that'd be.

Also, on quick glance, I think this might have issues under FreeRTOS due to the async context threadsafe. I will dig a bit more to understand how you're using it in place of IRQ callbacks, and how it would interact with the existing network IRQ/packet injection which happens in a separate task.

@earlephilhower
Copy link
Copy Markdown
Owner

Looks like you need to define a weak callback for TinyUSB somewhere in cores/rp2040, per CI:

/home/runner/work/arduino-pico/arduino-pico/system/arm-none-eabi/bin/../lib/gcc/arm-none-eabi/14.3.0/../../../../arm-none-eabi/bin/ld: /home/runner/arduino_ide/hardware/pico/rp2040/lib/rp2350/libpico.a(ncm_device.c.o): in function `recv_transfer_datagram_to_glue_logic':
/home/runner/work/arduino-pico/arduino-pico/pico-sdk/lib/tinyusb/src/class/net/ncm_device.c:645:(.text.tud_network_recv_renew+0x3a): undefined reference to `tud_network_recv_cb'

@functionpointer
Copy link
Copy Markdown
Contributor Author

I have tested Blink.ino on this branch (7aa537172c9aa3) and on master (c5a1255413fbaeb4) and there does seem to be some overhead. Program memory stays at 2% usage, but RAM jumps from 3% to 6%.
Here more details:

  master usb_ncm_new delta
PROGMEM 57128 Bytes 58424 Bytes 1296 Bytes
RAM 9788 Bytes 15896 Bytes 6108 Bytes

@earlephilhower
Copy link
Copy Markdown
Owner

The ROM's not too big a deal, I'd say, but losing 6KB of RAM for all sketches feels kind of painful. Let me see if there's a simple SDKOverride we can do to lazy allocate the network buffers (looking at the MAP, the ncm_epbuf is 6056 bytes in size).

FWIW I was able to test the net connection from the Pico to my Ubuntu 22.04 box (for some reason IP forwarding is not quite working, and I don't want to mess with things because I have several office VPN connections and Virtmgr/Docker rules). Just moving the fortune to the GW 192.168.137.1 of the Pico and I can ping it as well as receive it's fortune requests.

@earlephilhower
Copy link
Copy Markdown
Owner

I'm getting lots of crashes when a USB serial connection is active and the network interface is line

#0  _exit (status=status@entry=1) at /home/earle/Arduino/hardware/pico/rp2040/cores/rp2040/sdkoverride/newlib_interface.c:45
#1  0x100102b2 in panic (fmt=0x10020529 "ep %02X was already available") at /home/earle/Arduino/hardware/pico/rp2040/pico-sdk/src/rp2_common/pico_platform_panic/panic.c:82
#2  0x20000c80 in _hw_endpoint_buffer_control_update32 (ep=ep@entry=0x20002c00 <hw_endpoints+160>, and_mask=and_mask@entry=0, or_mask=or_mask@entry=37888) at /home/earle/Arduino/hardware/pico/rp2040/pico-sdk/lib/tinyusb/src/portable/raspberrypi/rp2040/rp2040_usb.c:108
#3  0x20000cfc in _hw_endpoint_buffer_control_set_value32 (ep=0x20002c00 <hw_endpoints+160>, value=37888) at /home/earle/Arduino/hardware/pico/rp2040/pico-sdk/lib/tinyusb/src/portable/raspberrypi/rp2040/rp2040_usb.h:122
#4  hw_endpoint_start_next_buffer (ep=ep@entry=0x20002c00 <hw_endpoints+160>) at /home/earle/Arduino/hardware/pico/rp2040/pico-sdk/lib/tinyusb/src/portable/raspberrypi/rp2040/rp2040_usb.c:191
#5  0x10015086 in hw_endpoint_xfer_start (ep=0x20002c00 <hw_endpoints+160>, buffer=buffer@entry=0x20001dcc <_cdcd_epbuf+64> "\r\nStarting NCM Ethernet port", total_len=total_len@entry=2) at /home/earle/Arduino/hardware/pico/rp2040/pico-sdk/lib/tinyusb/src/portable/raspberrypi/rp2040/rp2040_usb.c:216
#6  0x10014fa4 in hw_endpoint_xfer (ep_addr=<optimized out>, buffer=0x20001dcc <_cdcd_epbuf+64> "\r\nStarting NCM Ethernet port", total_bytes=2) at /home/earle/Arduino/hardware/pico/rp2040/pico-sdk/lib/tinyusb/src/portable/raspberrypi/rp2040/dcd_rp2040.c:148
#7  dcd_edpt_xfer (rhport=<optimized out>, ep_addr=<optimized out>, buffer=buffer@entry=0x20001dcc <_cdcd_epbuf+64> "\r\nStarting NCM Ethernet port", total_bytes=total_bytes@entry=2) at /home/earle/Arduino/hardware/pico/rp2040/pico-sdk/lib/tinyusb/src/portable/raspberrypi/rp2040/dcd_rp2040.c:517
#8  0x10012ab8 in usbd_edpt_xfer (rhport=<optimized out>, rhport@entry=0 '\000', ep_addr=<optimized out>, buffer=buffer@entry=0x20001dcc <_cdcd_epbuf+64> "\r\nStarting NCM Ethernet port", total_bytes=total_bytes@entry=2) at /home/earle/Arduino/hardware/pico/rp2040/pico-sdk/lib/tinyusb/src/device/usbd.c:1343
#9  0x1001344e in tud_cdc_n_write_flush (itf=itf@entry=0 '\000') at /home/earle/Arduino/hardware/pico/rp2040/pico-sdk/lib/tinyusb/src/class/cdc/cdc_device.c:213
#10 0x100137bc in cdcd_xfer_cb (rhport=0 '\000', ep_addr=130 '\202', result=<optimized out>, xferred_bytes=28) at /home/earle/Arduino/hardware/pico/rp2040/pico-sdk/lib/tinyusb/src/class/cdc/cdc_device.c:494
#11 0x100130d6 in tud_task_ext (timeout_ms=<optimized out>, in_isr=<optimized out>) at /home/earle/Arduino/hardware/pico/rp2040/pico-sdk/lib/tinyusb/src/device/usbd.c:659
#12 0x1000d83a in tud_task () at /home/earle/Arduino/hardware/pico/rp2040//pico-sdk/lib/tinyusb/src/device/usbd.h:69
#13 USBClass::usbIRQ () at /home/earle/Arduino/hardware/pico/rp2040/cores/rp2040/USB.cpp:436
#14 USBClass::usbIRQ () at /home/earle/Arduino/hardware/pico/rp2040/cores/rp2040/USB.cpp:431
#15 <signal handler called>
#16 0x1000ecf6 in interrupts () at /home/earle/Arduino/hardware/pico/rp2040/cores/rp2040/wiring_private.cpp:68
#17 0x100043a4 in ethernet_arch_lwip_gpio_mask () at /home/earle/Arduino/hardware/pico/rp2040/libraries/lwIP_Ethernet/src/LwipEthernet.cpp:133
#18 0x1000441a in ethernet_timeout_reached (context=<optimized out>, worker=<optimized out>) at /home/earle/Arduino/hardware/pico/rp2040/libraries/lwIP_Ethernet/src/LwipEthernet.cpp:242
#19 0x1001538e in async_context_base_execute_once (self=self@entry=0x20001770 <lwip_ethernet_async_context>) at /home/earle/Arduino/hardware/pico/rp2040/pico-sdk/src/rp2_common/pico_async_context/async_context_base.c:96
#20 0x1000c8fe in process_under_lock (self=self@entry=0x20001770 <lwip_ethernet_async_context>) at /home/earle/Arduino/hardware/pico/rp2040/cores/rp2040/sdkoverride/../../../pico-sdk/src/rp2_common/pico_async_context/async_context_threadsafe_background.c:257
#21 0x1000ca6c in low_priority_irq_handler () at /home/earle/Arduino/hardware/pico/rp2040/cores/rp2040/sdkoverride/../../../pico-sdk/src/rp2_common/pico_async_context/async_context_threadsafe_background.c:299
#22 <signal handler called>
#23 0x10003aec in NCMEthernet::usbInterfaceCB (this=0x20001948 <eth>, itf=itf@entry=2, dst=0x200127f3 "", len=85) at /home/earle/Arduino/hardware/pico/rp2040/libraries/lwIP_USB_NCM/src/utility/NCMEthernet.cpp:94
#24 0x10003bc8 in NCMEthernet::_usb_interface_cb (itf=2, dst=<optimized out>, len=<optimized out>, param=<optimized out>) at /home/earle/Arduino/hardware/pico/rp2040/libraries/lwIP_USB_NCM/src/utility/NCMEthernet.h:104
#25 0x1000dc4c in USBClass::setupUSBDescriptor (this=<optimized out>) at /home/earle/Arduino/hardware/pico/rp2040/cores/rp2040/USB.cpp:375
#26 USBClass::setupUSBDescriptor (this=<optimized out>) at /home/earle/Arduino/hardware/pico/rp2040/cores/rp2040/USB.cpp:331
#27 0x1000dd5e in USBClass::connect (this=this@entry=0x20001c44 <USB>) at /home/earle/Arduino/hardware/pico/rp2040/cores/rp2040/USB.cpp:483
#28 0x10003a8c in NCMEthernet::begin (this=this@entry=0x20001948 <eth>, mac_address=mac_address@entry=0x20001a4d <eth+261> "N1\033~\2276", net=net@entry=0x200019ec <eth+164>) at /home/earle/Arduino/hardware/pico/rp2040/libraries/lwIP_USB_NCM/src/utility/NCMEthernet.cpp:78
#29 0x100037be in LwipIntfDev<NCMEthernet>::begin (this=this@entry=0x20001948 <eth>, macAddress=macAddress@entry=0x0, mtu=mtu@entry=1500) at /home/earle/Arduino/hardware/pico/rp2040/libraries/lwIP_Ethernet/src/LwipIntfDev.h:396
#30 0x100038de in NCMEthernetlwIP::begin (this=this@entry=0x20001948 <eth>, macAddress=macAddress@entry=0x0, mtu=mtu@entry=1500) at /home/earle/Arduino/hardware/pico/rp2040/libraries/lwIP_USB_NCM/src/NCMEthernetlwIP.cpp:12
#31 0x100034f2 in setup () at /tmp/arduino_modified_sketch_29224/WiFiClient-NCMEthernet.ino:45
#32 0x1000ea58 in main () at /home/earle/Arduino/hardware/pico/rp2040/cores/rp2040/main.cpp:181

If I don't have a serial monitor/minicom active things are fine, but OTW I get something like the above almost every time. There may be some weird interaction between USB and Ethernet processing (which were never intertwined before) when this is actuve...

@functionpointer
Copy link
Copy Markdown
Contributor Author

Oh, I have not seen that before. I will try to replicate. Maybe the packet handoff is breaking some rules still

@earlephilhower
Copy link
Copy Markdown
Owner

I can't seem to find the magic incantation to make a PR against your PR here, but if you look at the last 3 commits to #3266 you'll see where I was able to make the ncd_device not use any RAM (and almost no ROM) when not used by making a dummy sdkoverride, using the original ncd_device.c inside your library folder (so only compiled when used), and removing the precompiled file from libpico.

@functionpointer
Copy link
Copy Markdown
Contributor Author

functionpointer commented Dec 10, 2025

Wouldn't that cause a dependency headache when upgrading lwip tinyusb?

@earlephilhower
Copy link
Copy Markdown
Owner

earlephilhower commented Dec 11, 2025

Wouldn't that cause a dependency headache when upgrading lwip tinyusb?

It's a very minor one. We have hand modified files from the SDK in cores/rp2040/sdkoverrides already, while this one is just a direct copy over (with an include path change which could be avoided by tweaking platform_inc.txt). It could be automated as part of makelibpico.sh in fact. TinyUSB is also pretty mature so I don't expect much churn in the USB networking side...

If you have a better way, though, of not needing the extra unused 6K of RAM for most users, I'm happy to hear it. But OTW, while I think this is pretty neat. I don't think I could make all other apps lose that space.

-edit- Now that I found what seems to be a reasonable way to do this, I might also do the same for MIDI and HID which takes 1/2 KB on every sketch. With RAM prices increasing 300% in the last 30 days every byte counts. 😆 -edit-

Copy link
Copy Markdown
Owner

@earlephilhower earlephilhower left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get your hesitation on doing the TUSB hackage, but 6K lost on every sketch is a real problem.

I'll take this as-is, with the 6k, and then combine the TUSB weak override for this and the MIDI, HID, and MSD devices in a follow-up.

One request, though, because getting this working is a little complicated. Can you add a small bit of documentation in the docs/ folder with the instructions you've got for Win and Linux? That way there's a way for a user to discover how to set it up without going through a (closed) PR.

@functionpointer
Copy link
Copy Markdown
Contributor Author

I understand 6k is problematic. I will take a look in the next couple days. I also tested freertos and it does indeed not work. I might fix that, too

@earlephilhower
Copy link
Copy Markdown
Owner

In #3272 I implemented a pretty simple and clean (enough) framework to avoid the extra RAM and flash when not needed. A fake local library that includes the xxx_device.c and a weak stub (cut-n-paste with a simple sed since the xxxd_device TinyUSB internal API is very clean) for each USB devicetype. It's the same idea as #3266 but done cleaner IMHO.

Let me test that out on all the cases it could affect, but when merged I think the same could be done here with almost no thought to get that 6K RAM and 1.2K flash back.

It should also let me increase the MSC (USB drive) buffer size to something much larger and faster, the MIDI buffers to larger for lsysex strings, and enable things like the USB audio and video without eating up RAM on most sketches.

@functionpointer
Copy link
Copy Markdown
Contributor Author

Very cool. I managed to read up on freeRTOS and how it is used by this project in the meantime. Pretty sure I will get USB NCM working with it.

@earlephilhower
Copy link
Copy Markdown
Owner

And could you please add your new library path libraries/lwIP_USB_NCM/ to tests/restyle.sh so we can get it all in the same format to make life easier down the road?

@earlephilhower
Copy link
Copy Markdown
Owner

Very cool. I managed to read up on freeRTOS and how it is used by this project in the meantime. Pretty sure I will get USB NCM working with it.

Would you like me to enable NCM and do the mini-fake-lib in that PR so on your side you don't need to? You'd just need a 1-line #lnclude <tusb-ndm.h> in one of your source .CPP files for the IDE to pull in the real sources, then. No rebuilding the libs or worrying about it that way...

@functionpointer
Copy link
Copy Markdown
Contributor Author

Sure, if it is easy for you to do

@earlephilhower
Copy link
Copy Markdown
Owner

I just merged the TUSB restructure.

You can remove the tusb-config and all library changes from the PR and just add #include <tusb-ncm.h> in one of your source files to get the networking code linked in w/o the dummy stubs. There is also no longer a need for a weak tusb_net_callback anymore in the core proper, since the CB will only ever be required when you link in your library.

Darn...just realized that now I need to remove my own weak callbacks for MSD, too. 😆

@functionpointer functionpointer marked this pull request as draft December 19, 2025 20:11
@functionpointer
Copy link
Copy Markdown
Contributor Author

functionpointer commented Dec 20, 2025

I have a question regarding deadlocking.
Why does

pbuf* pbuf = pbuf_alloc(PBUF_RAW, tot_len, PBUF_RAM);
not deadlock when using a real ethernet with interrupts but not using FreeRTOS?

The LwipIntfDev::_irq() function would be added to the interrupt pin by LwipIntfDev::begin(). Without FreeRTOS that calls LwipIntfDev::_lwipCallback directly, which calls LwipIntfDev::handlePackets(), which calls pbuf_alloc which is wrapped in lwip_wrap.cpp to acquire LWIPMutex. That entails calling LwipEthernet::ethernet_arch_lwip_begin() which calls async_context_acquire_lock_blocking(_context).

So what happens if a lower prio IRQ or the main loop already has the lock? My guess is we block and only a second higher priority IRQ can save us from deadlock. I must be missing something as that doesn't sound like a working ethernet implementation

@earlephilhower
Copy link
Copy Markdown
Owner

I'm traveling now but this is special cased or it would be bad, as you note.

We defer the irq in that case and it gets reenabled after the main app finishes. In the main irq callback we check the LWIP in progress flag and if set just disable the irq and do nothing then. When the LWIP mutex is feed by the app it will just reenabled the irq and it'll retry. See comments in LWIP_wrap:42 (sorry, GitHub won't let me link from phone)

@functionpointer
Copy link
Copy Markdown
Contributor Author

I don't think that addresses my concern.
__needsIRQEN is set by ethernet_arch_lwip_gpio_mask(), but that is not required to be called by user code.
Example: WiFiClient::connect is called in user code. That calls tcp_new(), which is wrapped to acquire LWIPMutex, but it never calls ethernet_arch_lwip_gpio_mask().
Therefore, while inside tcp_new() the external ethernet IRQ can fire, executing LwipIntfDev::_irq(), which ends up also trying to take LWIPMutex in a blocking fashion. Thus deadlock.

Unless I am missing something, it seems like the more the user code calls LWIP functions, the more likely an interrupt-driven ethernet could run into this.

This can't happen with polling ethernet because async_context_threadsafe_background ensures the polling in ethernet_timeout_reached() can only run with the mutex already acquired.

One way out would be to avoid blocking acquires in LwipIntfDev::_irq(). The lwip function calls could be moved to a new worker in the existing context. LwipIntfDev::_irq() would only call async_context_set_work_pending() which is IRQ safe.

Sorry for the offtopic, I just stumbled over this because NCM calls LwipIntfDev::_irq() and I want to make sure that doesn't break anything.

@earlephilhower
Copy link
Copy Markdown
Owner

I'm still traveling so it's painful to review code, but IIRC that flag is set as part of the wrapped calls. Writing this, I guess it's possible to just check the mutex state (but the mutex changes from cyw43 to all others so it's a pain).

If you have a faulting case because of this one the W or some wired Ethernet or 3rd party WiFi we can definitely look into it, but as of now I've not had any big reports. If it was really an issue I would have thought someone would have run into it in the couple years we've had this setup. My own stress testing of LWIP had been on both Cyw43 and W5100 irq enabled and I've had days of runtime with multiple clients without deadlocks seen.

I think this particular trouble is coming from the fact USB can IRQ whenever and wants to send in a packet (since we don't disable USB IRQs on LWIP calls). I guess it's possible, but I have a feeling there would be bad side effects of we didn't service then promptly (ie. starvation and disconnection from host). But that's just a guess right now

@functionpointer
Copy link
Copy Markdown
Contributor Author

I was having occasional panic's in my testing, so I have been debugging.

I think I have found a race condition in

err_t close() {
err_t err = ERR_OK;
if (_pcb) {
DEBUGV(":close\r\n");
tcp_arg(_pcb, nullptr);
tcp_sent(_pcb, nullptr);
tcp_recv(_pcb, nullptr);
tcp_err(_pcb, nullptr);
tcp_poll(_pcb, nullptr, 0);
err = tcp_close(_pcb);
if (err != ERR_OK) {
DEBUGV(":tc err %d\r\n", (int) err);
tcp_abort(_pcb);
err = ERR_ABRT;
}
_pcb = nullptr;
}
return err;
}

Between all the tcp_* calls the LWIP mutex gets released, including between tcp_poll() and tcp_close().

If in-between those a TCP RST packet is processed, LWIP will free the tcp_pcb (see tcp_in.c). Normally, LWIP would call the ClientContext::_error callback, which would set ClientContext::_pcb to NULL. However, that callback has removed already.

So tcp_close() is called with a pointer to already freed memory, which causes a double free of the struct tcp_pcb, which messes up memp.c's internal linked list.
This was hard to find, because without MEMP_SANITY_CHECK this goes unnoticed until a new tcp connection is opened.

@earlephilhower
Copy link
Copy Markdown
Owner

earlephilhower commented Feb 3, 2026

Interesting failure mode, nice job finding it! I think the solution would be a trivial instantiation of a LWIPMutex m; to the 1st line of the function to ensure it's all done as an atomic unit as far as the IRQ is concerned.

--edit--

Actually I don't think that will help you here. It's already safe for devices which use LWIPMutex to gate any packet inject IRQs, but USB isn't one of them, it's free-running. You might take the USBMutex instead, but maybe just `noInterrupts()/interrupts()`` around the function guts would be simpler and just as effective...

@earlephilhower
Copy link
Copy Markdown
Owner

Actually, it seems to me that the clearing of the callbacks is paranoia and not really needed anyway. tcp_close will deallocate the whole shebang, so why clear the callbacks (which are gone anyway after tcp_close)? So maybe drop everything before tcp_close()?

@functionpointer
Copy link
Copy Markdown
Contributor Author

Actually I don't think that will help you here. It's already safe for devices which use LWIPMutex to gate any packet inject IRQs, but USB isn't one of them, it's free-running.

USB packet injection is actually gated with LWIPMutex in my test, because i have improved the implementation. The USB mutex adds packets to a queue and sets a worker in LWIP's async_context pending. That worker fetches from the queue and processes the packets.

Calling tcp_close with NULL isn't a real option, because it contains LWIP_ERROR("tcp_close: invalid pcb", pcb != NULL, return ERR_ARG);. That means even if we don't clear the callbacks we can't safely call tcp_close.

if(_pcb) {
 // still a race condition here!
 tcp_close(_pcb);
}

Between the if statement and tcp_close() a packet could be received that frees _pcb and sets it to NULL using the callback.

As far as i can see the only real solution is LWIPMutex m;. And it is needed all devices, not just USB.

@earlephilhower
Copy link
Copy Markdown
Owner

As far as i can see the only real solution is LWIPMutex m;. And it is needed all devices, not just USB.

Sounds good to me. Maybe a separate PR to get more mileage while you're debugging NCM?

ClientContext just kind of worked and no stress test I ran ever seemed to hit that case, but if it's not already in a LWIP callback I guess that RST could work its way in.

@earlephilhower
Copy link
Copy Markdown
Owner

On inspection it seems to me the same race could happen in the abort() method as well. I've thrown a PR with the mutex just to get it out there and tested by a bunch of users ASAP while your work here continues. Thx!

functionpointer added a commit to functionpointer/arduino-pico that referenced this pull request Feb 13, 2026
This work in progress commit adds NULL checks in lwip_wrap.cpp to prevent double frees in ClientContext.h

See earlephilhower#3265 and earlephilhower#3368

It is alternative to earlephilhower#3370
It empowers Ethernet drivers to put packets in a queue.
This is in preparation of USB NCM (Network Control Model),
which requires a transmit queue to prevent deadlocks.
Additionally, the change may allow chained pbufs to be sent in the future.
This implementation in arduino-pico
enables an Ethernet connection with a host PC
over USB without any extra hardware.
Other uses of USB like Serial,
Mouse or Keyboard continue to work in parallel.

It has been tested on Windows and Linux.
MacOS should also work.

It supports baremetal and freertos, both
with and without IPv6.
@functionpointer
Copy link
Copy Markdown
Contributor Author

I have finally managed to get this working, it turned out to be way more work than expected.
The new implementation has been tested and works on freertos and baremetal, both with and without IPv6 support.
Additionally, i have used the sdkoverride you kindly introduced, that should save ram when NCM is not in use.
I have also added documentation for NCM.

Some key implementation details:

It turns out receive queues are not viable, as tinyusb frees packet buffers immediately after tud_network_recv_cb returns.

The new implementation therefore processes the entire packet inside tud_network_recv_cb. Doing this requires acquiring the lwIP mutex. However, at least on baremetal this isn't really possible. The lwIP mutex is recursive and the owner id is simpyl the core number. So acquiring will always succeed but without ensuring lwIP integrity.

The solution i have implemented is to schedule a worker in the lwIP async_context. This required adding __getEthernetContext().

This only works because it turns out return false in tud_network_recv_cb does not actually drop the packet. It leaves it in the buffer until tud_network_recv_renew() is called. So we can schedule the worker and return false, and the worker can then retry while holding both lwIP and USB mutexes.

Transmitting is different, it needs a transmit queue. That's because packets can be generated at any time, so we can't assume sendFrame will be called holding USB mutex. Furthermore, returning 0 will actually cause issues. Luckily, lwIP has a more flexible memory model than tinyusb's tud_network_recv_cb. We can keep a struct pbuf in a queue if we inform lwIP by calling pbuf_ref. Once we are done, we call pbuf_free to release it again. lwIP keeps an internal reference count, so pbuf_free only actually frees when appropriate.
Unfortunately, this requires access to the pbuf itself, not just its payload. That is why i changed the sendFrame function of all existing Ethernet implementations. That is a somewhat overarching change. I am happy to implement a different solution if something else is preferred. I chose this solution because it keeps the code simple and may prove helpful for other Ethernet adapters in the future. Access to the pbuf empowers them to use a transmit queue and maybe support transmitting chained pbufs in the future.

@functionpointer functionpointer marked this pull request as ready for review March 25, 2026 23:25
@earlephilhower
Copy link
Copy Markdown
Owner

earlephilhower commented Mar 26, 2026

Great work, sounds like a ton of effort!

Is this going to be dependant on your other prs, or standalone? And what order would you suggest going thru them?

I've not had much time recently to work on the repo, so I want to prioritize and get the order right when I do sit down and review these.

@functionpointer
Copy link
Copy Markdown
Contributor Author

The PRs are independent.

The two double free PRs #3370 #3377 are mutually exclusive.

#3372 is needed for integrity, especially with ESPHome.

#3408 is entirely optional, but getting something like it merged may help users find lock issues earlier.

I would suggest #3372 first, then this PR, the rest in any order. But they are independent, any order works.

@earlephilhower earlephilhower self-requested a review April 1, 2026 17:48
Copy link
Copy Markdown
Owner

@earlephilhower earlephilhower left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks very nice. A reading of the code looks good with minimal changes out of your new module, which is great. Let me do a few sanity checks on real HW with existing ENET and this USB NCM. If nothing pops up we're good to go!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants