From b6c958a41314b20bc4ffe0b50994b19e30b47a08 Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Sat, 31 Oct 2026 19:25:47 +0100 Subject: [PATCH 01/10] ncm: re-arm OUT reception before processing the received NTB netd_xfer_cb() started the next OUT transfer only after the glue logic had consumed every datagram of the NTB, so with CFG_TUD_NCM_OUT_NTB_N >= 2 the spare NTB never received while lwIP processed the current one: the host saw ~35 PING NAKs and a ~93 us gap per NTB on a U5A5 at HS. Start reception into a free NTB right after queueing the received one. On stm32u5a5nucleo (DWC2 DMA, NTB_N 2) host->device iperf goes 144 -> 231 Mbit/s. --- src/class/net/ncm_device.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/class/net/ncm_device.c b/src/class/net/ncm_device.c index b9963706ac..a1787af3fb 100644 --- a/src/class/net/ncm_device.c +++ b/src/class/net/ncm_device.c @@ -976,6 +976,8 @@ bool netd_xfer_cb(uint8_t rhport, uint8_t ep_addr, xfer_result_t result, uint32_ recv_put_ntb_into_ready_list(ncm_interface.recv_tinyusb_ntb); } ncm_interface.recv_tinyusb_ntb = NULL; + // re-arm with a free NTB (if any) before the glue logic processes this one, so reception overlaps processing + recv_try_to_start_new_reception(rhport); tud_network_recv_renew_r(rhport); } else if (ep_addr == ncm_interface.ep_in) { // transmission of an NTB finished From 6830aa9336805cd0eab4be16e08bfea7ef364259 Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Sat, 31 Oct 2026 19:25:47 +0100 Subject: [PATCH 02/10] net_lwip_webserver: tune NCM and lwIP for the high-throughput tier On LWIP_HIGH_THROUGHPUT MCUs: - two NCM NTBs each way, so USB keeps transferring while lwIP works - MEM_SIZE = TCP_SND_BUF + 2 * TCP_MSS: TCP TX segments come from the heap and the 1600 default cannot hold one MSS segment next to lwiperf's state, so iperf as client sent its header and then stalled - LWIP_CHKSUM_ALGORITHM 3: the 16-bit checksum took ~27% of the CPU This adds 13328 B of RAM. stm32h503nucleo and nanoch32v305 (32 KB) would overflow and now opt out with LWIP_HIGH_THROUGHPUT=0, as the RT101x boards do; the tightest remaining tier board (mimxrt1020_evk) keeps 18.7 KB free. stm32u5a5nucleo, default slave mode: host->device 75 -> 91 Mbit/s. --- examples/device/net_lwip_webserver/src/lwipopts.h | 5 +++++ examples/device/net_lwip_webserver/src/tusb_config.h | 6 +++--- hw/bsp/ch32v30x/boards/nanoch32v305/board.cmake | 3 +++ hw/bsp/ch32v30x/boards/nanoch32v305/board.mk | 2 ++ hw/bsp/stm32h5/boards/stm32h503nucleo/board.cmake | 1 + hw/bsp/stm32h5/boards/stm32h503nucleo/board.mk | 1 + 6 files changed, 15 insertions(+), 3 deletions(-) diff --git a/examples/device/net_lwip_webserver/src/lwipopts.h b/examples/device/net_lwip_webserver/src/lwipopts.h index 350120423c..a1c9db8667 100644 --- a/examples/device/net_lwip_webserver/src/lwipopts.h +++ b/examples/device/net_lwip_webserver/src/lwipopts.h @@ -62,6 +62,11 @@ #define PBUF_POOL_SIZE 8 // Must grow in step with TCP_SND_BUF (default MEMP_NUM_TCP_SEG=16 caps TCP_SND_BUF at 4*MSS). #define MEMP_NUM_TCP_SEG 16 + // TCP TX segments are allocated from the heap: the 1600 default cannot hold one full MSS segment + // next to lwiperf's state, so iperf as client sends its header and then stalls. + #define MEM_SIZE (TCP_SND_BUF + 2 * TCP_MSS) + // 32-bit word checksum, the default 16-bit one costs about a quarter of the CPU at HS throughput + #define LWIP_CHKSUM_ALGORITHM 3 #else #define TCP_WND (4 * TCP_MSS) #define PBUF_POOL_SIZE 4 diff --git a/examples/device/net_lwip_webserver/src/tusb_config.h b/examples/device/net_lwip_webserver/src/tusb_config.h index d94a87c997..8e604aa911 100644 --- a/examples/device/net_lwip_webserver/src/tusb_config.h +++ b/examples/device/net_lwip_webserver/src/tusb_config.h @@ -129,13 +129,13 @@ extern "C" { #define CFG_TUD_NCM_OUT_NTB_MAX_SIZE 2048 #endif -// Number of NCM transfer blocks for reception side +// Number of NCM transfer blocks: a second one lets USB transfer the next NTB while lwIP processes the current one #ifndef CFG_TUD_NCM_OUT_NTB_N - #define CFG_TUD_NCM_OUT_NTB_N 1 + #define CFG_TUD_NCM_OUT_NTB_N (LWIP_HIGH_THROUGHPUT ? 2 : 1) #endif #ifndef CFG_TUD_NCM_IN_NTB_N - #define CFG_TUD_NCM_IN_NTB_N 1 + #define CFG_TUD_NCM_IN_NTB_N (LWIP_HIGH_THROUGHPUT ? 2 : 1) #endif //-------------------------------------------------------------------- diff --git a/hw/bsp/ch32v30x/boards/nanoch32v305/board.cmake b/hw/bsp/ch32v30x/boards/nanoch32v305/board.cmake index 5f526ffeaa..48469302d4 100644 --- a/hw/bsp/ch32v30x/boards/nanoch32v305/board.cmake +++ b/hw/bsp/ch32v30x/boards/nanoch32v305/board.cmake @@ -2,4 +2,7 @@ set(LD_FLASH_SIZE 128K) set(LD_RAM_SIZE 32K) function(update_board TARGET) + target_compile_definitions(${TARGET} PUBLIC + LWIP_HIGH_THROUGHPUT=0 + ) endfunction() diff --git a/hw/bsp/ch32v30x/boards/nanoch32v305/board.mk b/hw/bsp/ch32v30x/boards/nanoch32v305/board.mk index 580cf1a764..2f7f4d59f6 100644 --- a/hw/bsp/ch32v30x/boards/nanoch32v305/board.mk +++ b/hw/bsp/ch32v30x/boards/nanoch32v305/board.mk @@ -1,3 +1,5 @@ +CFLAGS += -DLWIP_HIGH_THROUGHPUT=0 + LDFLAGS += \ -Wl,--defsym=__FLASH_SIZE=128K \ -Wl,--defsym=__RAM_SIZE=32K \ diff --git a/hw/bsp/stm32h5/boards/stm32h503nucleo/board.cmake b/hw/bsp/stm32h5/boards/stm32h503nucleo/board.cmake index 5978758fc5..213e93d137 100644 --- a/hw/bsp/stm32h5/boards/stm32h503nucleo/board.cmake +++ b/hw/bsp/stm32h5/boards/stm32h503nucleo/board.cmake @@ -8,5 +8,6 @@ function(update_board TARGET) ) target_compile_definitions(${BOARD_TARGET} PUBLIC CFG_EXAMPLE_VIDEO_READONLY + LWIP_HIGH_THROUGHPUT=0 ) endfunction() diff --git a/hw/bsp/stm32h5/boards/stm32h503nucleo/board.mk b/hw/bsp/stm32h5/boards/stm32h503nucleo/board.mk index 497dd894e4..3f5662dbc3 100644 --- a/hw/bsp/stm32h5/boards/stm32h503nucleo/board.mk +++ b/hw/bsp/stm32h5/boards/stm32h503nucleo/board.mk @@ -4,6 +4,7 @@ CFLAGS += \ -DSTM32H503xx \ -DHSE_VALUE=24000000 \ -DCFG_EXAMPLE_VIDEO_READONLY \ + -DLWIP_HIGH_THROUGHPUT=0 \ # For flash-jlink target JLINK_DEVICE = stm32h503rb From bd302e2e1ab19905f598a9658535b8e00e6be3b4 Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Sat, 31 Oct 2026 19:25:47 +0100 Subject: [PATCH 03/10] stm32u5 nucleo: touch OTG registers in the VBUS ADC ISR only on change The 1 kHz VBUS-sense ADC ISR read and rewrote GOTGCTL/GCCFG on every sample. Any OTG register access, even a read, made while a TX FIFO holds a packet one word short of complete zeroes that endpoint's DIEPTSIZ.XFRSIZ: when the ISR preempted a slave-mode FIFO push at that point, the remaining packets were never written and the IN endpoint NAKed forever. On stm32u5a5nucleo NCM device->host TCP wedged within seconds. Reproduced deterministically on stm32u5a5nucleo OTG_HS by injecting one register read before the last word of a packet push (cdc_msc_throughput, CDC IN): every run wedged on the first multi-packet transfer; reads at any other word, and 2 or 10 us pure delays before the last word, never did. Not described in ES0553, RM0456 or the DWC2 databook. Cache the applied VBUS state and access OTG only when it changes, after the stack has enabled the B-valid override. Slave-mode NCM TX now runs at 79.5 Mbit/s with no wedge. stm32u575nucleo (OTG_FS) has the same ISR and gets the same change, untested on hardware. --- hw/bsp/stm32u5/boards/stm32u575nucleo/board.h | 21 +++++++++++++--- hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h | 25 ++++++++++++++----- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/hw/bsp/stm32u5/boards/stm32u575nucleo/board.h b/hw/bsp/stm32u5/boards/stm32u575nucleo/board.h index 6f2f7596b1..c31380c0e2 100644 --- a/hw/bsp/stm32u5/boards/stm32u575nucleo/board.h +++ b/hw/bsp/stm32u5/boards/stm32u575nucleo/board.h @@ -192,6 +192,12 @@ static inline void board_vbus_sense_init(void) { } void ADC1_IRQHandler(void) { + // VBUS state last applied to OTG_FS, -1 until the stack enables the B-valid override. + // Touch OTG_FS only when that state changes: any OTG register access, even a read, made while a TX FIFO holds a + // packet one word short of complete zeroes that endpoint's DIEPTSIZ.XFRSIZ (seen on stm32u5a5nucleo OTG_HS). If this + // ISR preempts the stack's slave-mode FIFO push at that point, the IN endpoint NAKs forever. + static int8_t vbus_state = -1; + if(LL_ADC_IsActiveFlag_EOC(ADC1) != 0) { /* Clear flag ADC group regular end of unitary conversion */ LL_ADC_ClearFlag_EOC(ADC1); @@ -199,10 +205,17 @@ void ADC1_IRQHandler(void) { * with R1 = 330kOhm and R2 = 50kOhm */ const uint32_t threshold = 4500 * 50 / (50 + 330) * 16384 / 3300; - if((ADC1->DR > threshold) && (USB_OTG_FS->GOTGCTL & USB_OTG_GOTGCTL_BVALOEN)) { - USB_OTG_FS->GOTGCTL |= USB_OTG_GOTGCTL_BVALOVAL; - } else { - USB_OTG_FS->GOTGCTL &= ~USB_OTG_GOTGCTL_BVALOVAL; + const int8_t vbus_valid = (ADC1->DR > threshold) ? 1 : 0; + if (vbus_state < 0 && (USB_OTG_FS->GOTGCTL & USB_OTG_GOTGCTL_BVALOEN)) { + vbus_state = (int8_t) !vbus_valid; // override just enabled by the stack: apply the current state once + } + if (vbus_state >= 0 && vbus_valid != vbus_state) { + vbus_state = vbus_valid; + if (vbus_valid) { + USB_OTG_FS->GOTGCTL |= USB_OTG_GOTGCTL_BVALOVAL; + } else { + USB_OTG_FS->GOTGCTL &= ~USB_OTG_GOTGCTL_BVALOVAL; + } } } if(LL_ADC_IsActiveFlag_OVR(ADC1) != 0) { diff --git a/hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h b/hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h index 8b3118d79c..4afaddabb4 100644 --- a/hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h +++ b/hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h @@ -227,6 +227,12 @@ static inline void board_vbus_sense_init(void) { } void ADC1_2_IRQHandler(void) { + // VBUS state last applied to OTG_HS, -1 until the stack enables the B-valid override. + // Touch OTG_HS only when that state changes: any OTG_HS register access, even a read, made while a TX FIFO holds a + // packet one word short of complete zeroes that endpoint's DIEPTSIZ.XFRSIZ. If this ISR preempts the stack's slave-mode + // FIFO push at that point, the rest of the transfer is never written and the IN endpoint NAKs forever. + static int8_t vbus_state = -1; + if(LL_ADC_IsActiveFlag_EOC(ADC1) != 0) { /* Clear flag ADC group regular end of unitary conversion */ LL_ADC_ClearFlag_EOC(ADC1); @@ -234,12 +240,19 @@ void ADC1_2_IRQHandler(void) { * with R1 = 330kOhm and R2 = 50kOhm */ const uint32_t threshold = 4500 * 50 / (50 + 330) * 16384 / 3300; - if((ADC1->DR > threshold) && (USB_OTG_HS->GOTGCTL & USB_OTG_GOTGCTL_BVALOEN)) { - USB_OTG_HS->GOTGCTL |= USB_OTG_GOTGCTL_BVALOVAL; - USB_OTG_HS->GCCFG |= USB_OTG_GCCFG_VBVALOVAL; - } else { - USB_OTG_HS->GOTGCTL &= ~USB_OTG_GOTGCTL_BVALOVAL; - USB_OTG_HS->GCCFG &= ~USB_OTG_GCCFG_VBVALOVAL; + const int8_t vbus_valid = (ADC1->DR > threshold) ? 1 : 0; + if (vbus_state < 0 && (USB_OTG_HS->GOTGCTL & USB_OTG_GOTGCTL_BVALOEN)) { + vbus_state = (int8_t) !vbus_valid; // override just enabled by the stack: apply the current state once + } + if (vbus_state >= 0 && vbus_valid != vbus_state) { + vbus_state = vbus_valid; + if (vbus_valid) { + USB_OTG_HS->GOTGCTL |= USB_OTG_GOTGCTL_BVALOVAL; + USB_OTG_HS->GCCFG |= USB_OTG_GCCFG_VBVALOVAL; + } else { + USB_OTG_HS->GOTGCTL &= ~USB_OTG_GOTGCTL_BVALOVAL; + USB_OTG_HS->GCCFG &= ~USB_OTG_GCCFG_VBVALOVAL; + } } } if(LL_ADC_IsActiveFlag_OVR(ADC1) != 0) { From a38c6c6709706036a9449044c93740d294f3e7bd Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Sat, 31 Oct 2026 23:17:14 +0100 Subject: [PATCH 04/10] stm32u5 nucleo: resync the VBUS override after each stack start The change-only ISR applied the sensed state once, when it first saw BVALOEN, and then trusted its cached state. dcd_init() forces BVALOVAL on at every (re)start, so after tud_deinit()/tud_init() or a cable cycle the cache could disagree with the registers. Read and correct the override for the first 10 samples after tusb_inited(), within the 100 ms attach debounce before the host enumerates, and touch OTG only on VBUS change afterwards. Also document the example's new NCM NTB counts in its README. --- examples/device/net_lwip_webserver/README.md | 4 +- hw/bsp/stm32u5/boards/stm32u575nucleo/board.h | 48 +++++++++++------ hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h | 52 +++++++++++++------ 3 files changed, 70 insertions(+), 34 deletions(-) diff --git a/examples/device/net_lwip_webserver/README.md b/examples/device/net_lwip_webserver/README.md index b4d4291006..3197ac5c58 100644 --- a/examples/device/net_lwip_webserver/README.md +++ b/examples/device/net_lwip_webserver/README.md @@ -33,8 +33,8 @@ Notable `tusb_config.h` settings: // NCM tuning (see ncm.h for performance notes) #define CFG_TUD_NCM_IN_NTB_MAX_SIZE 2048 #define CFG_TUD_NCM_OUT_NTB_MAX_SIZE 4096 // 2048 on low-RAM MCUs -#define CFG_TUD_NCM_OUT_NTB_N 1 -#define CFG_TUD_NCM_IN_NTB_N 1 +#define CFG_TUD_NCM_OUT_NTB_N 2 // 1 on low-RAM MCUs +#define CFG_TUD_NCM_IN_NTB_N 2 // 1 on low-RAM MCUs ``` ## Building diff --git a/hw/bsp/stm32u5/boards/stm32u575nucleo/board.h b/hw/bsp/stm32u5/boards/stm32u575nucleo/board.h index c31380c0e2..5aa459de3f 100644 --- a/hw/bsp/stm32u5/boards/stm32u575nucleo/board.h +++ b/hw/bsp/stm32u5/boards/stm32u575nucleo/board.h @@ -191,12 +191,25 @@ static inline void board_vbus_sense_init(void) { HAL_TIM_Base_Start(&htim1); } +static inline void board_vbus_override_write(bool vbus_valid) { + if (vbus_valid) { + USB_OTG_FS->GOTGCTL |= USB_OTG_GOTGCTL_BVALOVAL; + } else { + USB_OTG_FS->GOTGCTL &= ~USB_OTG_GOTGCTL_BVALOVAL; + } +} + +// Samples (1 ms each) the override is synced from OTG_FS registers after the stack starts +#define BOARD_VBUS_SYNC_SAMPLES 10 + void ADC1_IRQHandler(void) { - // VBUS state last applied to OTG_FS, -1 until the stack enables the B-valid override. - // Touch OTG_FS only when that state changes: any OTG register access, even a read, made while a TX FIFO holds a - // packet one word short of complete zeroes that endpoint's DIEPTSIZ.XFRSIZ (seen on stm32u5a5nucleo OTG_HS). If this - // ISR preempts the stack's slave-mode FIFO push at that point, the IN endpoint NAKs forever. - static int8_t vbus_state = -1; + // Any OTG register access, even a read, made while a TX FIFO holds a packet one word short of complete zeroes that + // endpoint's DIEPTSIZ.XFRSIZ (seen on stm32u5a5nucleo OTG_HS): if this ISR preempts the stack's slave-mode FIFO push + // at that point, the IN endpoint NAKs forever. dcd_init() forces the override on at every (re)start, so the registers + // are read and corrected only for the first samples after it (the host does not enumerate, so no FIFO push, within + // 100 ms of connect); afterwards OTG_FS is touched only when VBUS changes. + static uint8_t sync_samples = BOARD_VBUS_SYNC_SAMPLES; + static bool vbus_state; if(LL_ADC_IsActiveFlag_EOC(ADC1) != 0) { /* Clear flag ADC group regular end of unitary conversion */ @@ -205,17 +218,22 @@ void ADC1_IRQHandler(void) { * with R1 = 330kOhm and R2 = 50kOhm */ const uint32_t threshold = 4500 * 50 / (50 + 330) * 16384 / 3300; - const int8_t vbus_valid = (ADC1->DR > threshold) ? 1 : 0; - if (vbus_state < 0 && (USB_OTG_FS->GOTGCTL & USB_OTG_GOTGCTL_BVALOEN)) { - vbus_state = (int8_t) !vbus_valid; // override just enabled by the stack: apply the current state once - } - if (vbus_state >= 0 && vbus_valid != vbus_state) { - vbus_state = vbus_valid; - if (vbus_valid) { - USB_OTG_FS->GOTGCTL |= USB_OTG_GOTGCTL_BVALOVAL; - } else { - USB_OTG_FS->GOTGCTL &= ~USB_OTG_GOTGCTL_BVALOVAL; + const bool vbus_sensed = ADC1->DR > threshold; + if (!tusb_inited()) { + sync_samples = BOARD_VBUS_SYNC_SAMPLES; + } else if (sync_samples > 0) { + const uint32_t gotgctl = USB_OTG_FS->GOTGCTL; + const bool override_on = (gotgctl & USB_OTG_GOTGCTL_BVALOEN) != 0; + vbus_state = vbus_sensed && override_on; + if (vbus_state != ((gotgctl & USB_OTG_GOTGCTL_BVALOVAL) != 0)) { + board_vbus_override_write(vbus_state); + } + if (override_on) { + sync_samples--; } + } else if (vbus_sensed != vbus_state) { + vbus_state = vbus_sensed; + board_vbus_override_write(vbus_state); } } if(LL_ADC_IsActiveFlag_OVR(ADC1) != 0) { diff --git a/hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h b/hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h index 4afaddabb4..db5ff7a1e8 100644 --- a/hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h +++ b/hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h @@ -226,12 +226,27 @@ static inline void board_vbus_sense_init(void) { HAL_TIM_Base_Start(&htim1); } +static inline void board_vbus_override_write(bool vbus_valid) { + if (vbus_valid) { + USB_OTG_HS->GOTGCTL |= USB_OTG_GOTGCTL_BVALOVAL; + USB_OTG_HS->GCCFG |= USB_OTG_GCCFG_VBVALOVAL; + } else { + USB_OTG_HS->GOTGCTL &= ~USB_OTG_GOTGCTL_BVALOVAL; + USB_OTG_HS->GCCFG &= ~USB_OTG_GCCFG_VBVALOVAL; + } +} + +// Samples (1 ms each) the override is synced from OTG_HS registers after the stack starts +#define BOARD_VBUS_SYNC_SAMPLES 10 + void ADC1_2_IRQHandler(void) { - // VBUS state last applied to OTG_HS, -1 until the stack enables the B-valid override. - // Touch OTG_HS only when that state changes: any OTG_HS register access, even a read, made while a TX FIFO holds a - // packet one word short of complete zeroes that endpoint's DIEPTSIZ.XFRSIZ. If this ISR preempts the stack's slave-mode - // FIFO push at that point, the rest of the transfer is never written and the IN endpoint NAKs forever. - static int8_t vbus_state = -1; + // Any OTG_HS register access, even a read, made while a TX FIFO holds a packet one word short of complete zeroes that + // endpoint's DIEPTSIZ.XFRSIZ: if this ISR preempts the stack's slave-mode FIFO push at that point, the rest of the + // transfer is never written and the IN endpoint NAKs forever. dcd_init() forces the override on at every (re)start, + // so the registers are read and corrected only for the first samples after it (the host does not enumerate, so no + // FIFO push, within 100 ms of connect); afterwards OTG_HS is touched only when VBUS changes. + static uint8_t sync_samples = BOARD_VBUS_SYNC_SAMPLES; + static bool vbus_state; if(LL_ADC_IsActiveFlag_EOC(ADC1) != 0) { /* Clear flag ADC group regular end of unitary conversion */ @@ -240,19 +255,22 @@ void ADC1_2_IRQHandler(void) { * with R1 = 330kOhm and R2 = 50kOhm */ const uint32_t threshold = 4500 * 50 / (50 + 330) * 16384 / 3300; - const int8_t vbus_valid = (ADC1->DR > threshold) ? 1 : 0; - if (vbus_state < 0 && (USB_OTG_HS->GOTGCTL & USB_OTG_GOTGCTL_BVALOEN)) { - vbus_state = (int8_t) !vbus_valid; // override just enabled by the stack: apply the current state once - } - if (vbus_state >= 0 && vbus_valid != vbus_state) { - vbus_state = vbus_valid; - if (vbus_valid) { - USB_OTG_HS->GOTGCTL |= USB_OTG_GOTGCTL_BVALOVAL; - USB_OTG_HS->GCCFG |= USB_OTG_GCCFG_VBVALOVAL; - } else { - USB_OTG_HS->GOTGCTL &= ~USB_OTG_GOTGCTL_BVALOVAL; - USB_OTG_HS->GCCFG &= ~USB_OTG_GCCFG_VBVALOVAL; + const bool vbus_sensed = ADC1->DR > threshold; + if (!tusb_inited()) { + sync_samples = BOARD_VBUS_SYNC_SAMPLES; + } else if (sync_samples > 0) { + const uint32_t gotgctl = USB_OTG_HS->GOTGCTL; + const bool override_on = (gotgctl & USB_OTG_GOTGCTL_BVALOEN) != 0; + vbus_state = vbus_sensed && override_on; + if (vbus_state != ((gotgctl & USB_OTG_GOTGCTL_BVALOVAL) != 0)) { + board_vbus_override_write(vbus_state); + } + if (override_on) { + sync_samples--; } + } else if (vbus_sensed != vbus_state) { + vbus_state = vbus_sensed; + board_vbus_override_write(vbus_state); } } if(LL_ADC_IsActiveFlag_OVR(ADC1) != 0) { From b33cd944858be1ab50f2247e78435c9c75854adf Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Sat, 31 Oct 2026 23:30:37 +0100 Subject: [PATCH 05/10] net_lwip_webserver: remove the DNS server DHCP advertised the device as DNS server, and Windows ranks the NCM link (metric 25) above the LAN, so it asked the device first. dnserver answers only tiny.usb and drops every other query, so after each attach the first lookups stalled 12 s each before Windows fell back. With the server and its DHCP option gone, nothing listens on port 53, lwIP answers stray queries with port unreachable and lookups take tens of ms. --- docs/reference/class/network.rst | 2 +- .../device/net_lwip_webserver/CMakeLists.txt | 1 - examples/device/net_lwip_webserver/Makefile | 1 - examples/device/net_lwip_webserver/README.md | 4 ++-- examples/device/net_lwip_webserver/src/main.c | 20 +++++-------------- 5 files changed, 8 insertions(+), 20 deletions(-) diff --git a/docs/reference/class/network.rst b/docs/reference/class/network.rst index 5dfdb50f93..fe4fb1e3b3 100644 --- a/docs/reference/class/network.rst +++ b/docs/reference/class/network.rst @@ -136,7 +136,7 @@ the NTB sizes or their ``*_NTB_N`` counts. Keep descriptor capabilities and runtime responses consistent with the enabled NCM features. The :doc:`../../examples/device/net_lwip_webserver` example includes NCM and -ECM/RNDIS descriptor sets, lwIP integration, DHCP, DNS, link-state changes, and +ECM/RNDIS descriptor sets, lwIP integration, DHCP, link-state changes, and host setup notes. Specifications used: *CDC Ethernet Control Model*, Revision 1.2, and *CDC diff --git a/examples/device/net_lwip_webserver/CMakeLists.txt b/examples/device/net_lwip_webserver/CMakeLists.txt index 9c4349d247..46f0152404 100644 --- a/examples/device/net_lwip_webserver/CMakeLists.txt +++ b/examples/device/net_lwip_webserver/CMakeLists.txt @@ -41,7 +41,6 @@ target_include_directories(${PROJECT_NAME} PUBLIC # lib/networking sources target_sources(${PROJECT_NAME} PUBLIC ${TOP}/lib/networking/dhserver.c - ${TOP}/lib/networking/dnserver.c ${TOP}/lib/networking/rndis_reports.c ) diff --git a/examples/device/net_lwip_webserver/Makefile b/examples/device/net_lwip_webserver/Makefile index 6002039b35..c08a69fcca 100644 --- a/examples/device/net_lwip_webserver/Makefile +++ b/examples/device/net_lwip_webserver/Makefile @@ -62,7 +62,6 @@ SRC_C += \ lib/lwip/src/apps/http/fs.c \ lib/lwip/src/apps/lwiperf/lwiperf.c \ lib/networking/dhserver.c \ - lib/networking/dnserver.c \ lib/networking/rndis_reports.c include ../../../hw/bsp/family_rules.mk diff --git a/examples/device/net_lwip_webserver/README.md b/examples/device/net_lwip_webserver/README.md index 3197ac5c58..dfcb738568 100644 --- a/examples/device/net_lwip_webserver/README.md +++ b/examples/device/net_lwip_webserver/README.md @@ -1,11 +1,11 @@ # Network Web Server (lwIP) -A USB virtual network adapter that runs a small lwIP stack on the device, serving DHCP, DNS, and a web page over USB. +A USB virtual network adapter that runs a small lwIP stack on the device, serving DHCP and a web page over USB. ## What it does - Brings up a USB network interface. Depending on the target MCU the build is either CDC-NCM (the default for most MCUs) or a dual RNDIS + CDC-ECM device that offers two configurations and lets the host pick its preferred one (Windows uses RNDIS, macOS uses CDC-ECM, Linux works with either). The NCM build also ships a BOS / Microsoft OS 2.0 descriptor so Windows auto-loads its NCM driver. -- The device takes IP address 192.168.7.1 and runs a DHCP server (handing out 192.168.7.2–192.168.7.4), a DNS server (resolves `tiny.usb`), and an HTTP server. +- The device takes IP address 192.168.7.1 and runs a DHCP server (handing out 192.168.7.2–192.168.7.4) and an HTTP server. - Pressing the board button toggles the network link state (up/down) to simulate an Ethernet cable being unplugged/plugged. - On higher-RAM MCUs it also starts an iperf TCP server for throughput testing. - Blinks the board LED to indicate USB state (not mounted / mounted / suspended). diff --git a/examples/device/net_lwip_webserver/src/main.c b/examples/device/net_lwip_webserver/src/main.c index 030c206bde..cd6734e21f 100644 --- a/examples/device/net_lwip_webserver/src/main.c +++ b/examples/device/net_lwip_webserver/src/main.c @@ -30,7 +30,7 @@ this appears as either a RNDIS or CDC-ECM USB virtual network adapter; the OS pi RNDIS should be valid on Linux and Windows hosts, and CDC-ECM should be valid on Linux and macOS hosts -The MCU appears to the host as IP address 192.168.7.1, and provides a DHCP server, DNS server, and web server. +The MCU appears to the host as IP address 192.168.7.1, and provides a DHCP server and web server. Link State Control: - Press the user button to toggle the network link state (UP/DOWN) @@ -53,7 +53,6 @@ try changing the first byte of tud_network_mac_address[] below from 0x02 to 0x00 #include "tusb.h" #include "dhserver.h" -#include "dnserver.h" #include "httpd.h" #include "lwip/ethip6.h" #include "lwip/init.h" @@ -104,8 +103,9 @@ static dhcp_entry_t entries[] = { static const dhcp_config_t dhcp_config = { .router = INIT_IP4(0, 0, 0, 0), /* router address (if any) */ .port = 67, /* listen port */ - .dns = INIT_IP4(192, 168, 7, 1), /* dns server (if any) */ - "usb", /* dns suffix */ + /* no dns server: the host may rank this link's DNS first and stall every lookup it cannot answer */ + .dns = INIT_IP4(0, 0, 0, 0), + NULL, /* dns suffix */ TU_ARRAY_SIZE(entries), /* num entry */ entries /* entries */ }; @@ -190,15 +190,6 @@ static void init_lwip(void) { #endif } -/* handle any DNS requests from dns-server */ -static bool dns_query_proc(const char *name, ip4_addr_t *addr) { - if (0 == strcmp(name, "tiny.usb")) { - *addr = ipaddr; - return true; - } - return false; -} - bool tud_network_recv_cb(const uint8_t *src, uint16_t size) { struct netif *netif = &netif_data; @@ -285,11 +276,10 @@ int main(void) { board_init_after_tusb(); - /* initialize lwip, dhcp-server, dns-server, and http */ + /* initialize lwip, dhcp-server, and http */ init_lwip(); while (!netif_is_up(&netif_data)); while (dhserv_init(&dhcp_config) != ERR_OK); - while (dnserv_init(IP_ADDR_ANY, 53, dns_query_proc) != ERR_OK); httpd_init(); #ifdef INCLUDE_IPERF From 4c2ac6b9968d4fc85f620bb106776649af5155e7 Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Sun, 1 Nov 2026 11:30:09 +0100 Subject: [PATCH 06/10] stm32u5 nucleo: clear the VBUS override while VBUS is absent A tud_deinit()/tud_init() pair inside one 1 ms ADC sample is not seen by the ISR, so the resync window is not re-armed: dcd_init() forces the override on and, with VBUS sensed absent and unchanged, the ISR never writes it again. The core then reports a valid session with no VBUS. While VBUS is sensed absent, read the override bits and clear them when set. No host is attached then, so no slave-mode TX FIFO push can collide with the reads; with VBUS present OTG is still touched only on change. --- hw/bsp/stm32u5/boards/stm32u575nucleo/board.h | 6 +++++- hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/hw/bsp/stm32u5/boards/stm32u575nucleo/board.h b/hw/bsp/stm32u5/boards/stm32u575nucleo/board.h index 5aa459de3f..84e6106f20 100644 --- a/hw/bsp/stm32u5/boards/stm32u575nucleo/board.h +++ b/hw/bsp/stm32u5/boards/stm32u575nucleo/board.h @@ -207,7 +207,9 @@ void ADC1_IRQHandler(void) { // endpoint's DIEPTSIZ.XFRSIZ (seen on stm32u5a5nucleo OTG_HS): if this ISR preempts the stack's slave-mode FIFO push // at that point, the IN endpoint NAKs forever. dcd_init() forces the override on at every (re)start, so the registers // are read and corrected only for the first samples after it (the host does not enumerate, so no FIFO push, within - // 100 ms of connect); afterwards OTG_FS is touched only when VBUS changes. + // 100 ms of connect); afterwards OTG_FS is touched only when VBUS changes, or while VBUS is absent: a + // tud_deinit()/tud_init() between two samples leaves the override forced on with no VBUS, and with no host attached + // there is no FIFO push to collide with. static uint8_t sync_samples = BOARD_VBUS_SYNC_SAMPLES; static bool vbus_state; @@ -234,6 +236,8 @@ void ADC1_IRQHandler(void) { } else if (vbus_sensed != vbus_state) { vbus_state = vbus_sensed; board_vbus_override_write(vbus_state); + } else if (!vbus_sensed && (USB_OTG_FS->GOTGCTL & USB_OTG_GOTGCTL_BVALOVAL) != 0) { + board_vbus_override_write(false); } } if(LL_ADC_IsActiveFlag_OVR(ADC1) != 0) { diff --git a/hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h b/hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h index db5ff7a1e8..1fc44804d1 100644 --- a/hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h +++ b/hw/bsp/stm32u5/boards/stm32u5a5nucleo/board.h @@ -244,7 +244,9 @@ void ADC1_2_IRQHandler(void) { // endpoint's DIEPTSIZ.XFRSIZ: if this ISR preempts the stack's slave-mode FIFO push at that point, the rest of the // transfer is never written and the IN endpoint NAKs forever. dcd_init() forces the override on at every (re)start, // so the registers are read and corrected only for the first samples after it (the host does not enumerate, so no - // FIFO push, within 100 ms of connect); afterwards OTG_HS is touched only when VBUS changes. + // FIFO push, within 100 ms of connect); afterwards OTG_HS is touched only when VBUS changes, or while VBUS is absent: + // a tud_deinit()/tud_init() between two samples leaves the override forced on with no VBUS, and with no host attached + // there is no FIFO push to collide with. static uint8_t sync_samples = BOARD_VBUS_SYNC_SAMPLES; static bool vbus_state; @@ -271,6 +273,9 @@ void ADC1_2_IRQHandler(void) { } else if (vbus_sensed != vbus_state) { vbus_state = vbus_sensed; board_vbus_override_write(vbus_state); + } else if (!vbus_sensed && ((USB_OTG_HS->GOTGCTL & USB_OTG_GOTGCTL_BVALOVAL) != 0 || + (USB_OTG_HS->GCCFG & USB_OTG_GCCFG_VBVALOVAL) != 0)) { + board_vbus_override_write(false); } } if(LL_ADC_IsActiveFlag_OVR(ADC1) != 0) { From 43757f62a6f2c74f823afb2c835d2b12207cb377 Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Sun, 1 Nov 2026 12:41:01 +0100 Subject: [PATCH 07/10] net_lwip_webserver: renew an empty received frame handle_incoming_packet() hands tud_network_recv_cb() a size of 0 for a zero-length ECM transfer or an RNDIS packet that fails validation. The callback returned true without tud_network_recv_renew(), so the class did not renew on its behalf, OUT was never re-armed and the link stalled for good. Renew on every path that returns true. --- examples/device/net_lwip_webserver/src/main.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/device/net_lwip_webserver/src/main.c b/examples/device/net_lwip_webserver/src/main.c index cd6734e21f..504f7bccd6 100644 --- a/examples/device/net_lwip_webserver/src/main.c +++ b/examples/device/net_lwip_webserver/src/main.c @@ -212,10 +212,11 @@ bool tud_network_recv_cb(const uint8_t *src, uint16_t size) { printf("ERROR: netif input failed\n"); pbuf_free(p); } - // Signal tinyusb that the current frame has been processed. - tud_network_recv_renew(); } + // Signal tinyusb that the current frame has been processed. An empty frame (a ZLP, or an RNDIS packet that failed + // validation) must be renewed too: returning true without it leaves OUT un-armed and stalls the link. + tud_network_recv_renew(); return true; } From 25d3bc5da749a767f2509aa352ecc3be071e4b2c Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Wed, 11 Nov 2026 23:34:40 +0100 Subject: [PATCH 08/10] ncm, net_lwip_webserver: receive frames without copying them tud_network_recv_cb() had to copy each datagram: NCM returns the NTB to the free list as soon as the callback returns for its last datagram. Add tud_network_recv_hold()/tud_network_recv_release(): a hold keeps the NTB allocated after the callback, so the datagram can be used in place. NTBs are counted per hold and freed on the last release, and the counts live outside ncm_interface so a reset keeps a held NTB off the free list. A hold is refused, and the frame must be copied, while every other NTB is held: one NTB always cycles, so frames the network stack keeps (out-of-order TCP segments, IP fragments) cannot stall reception. With a single receive NTB it always refuses. On high-throughput MCUs net_lwip_webserver hands NCM frames to lwIP as PBUF_REF custom pbufs whose free hook releases the hold, and copies only a frame the class refuses. stm32u5a5nucleo, host->device iperf: slave 90.6 -> 165 Mbit/s, DMA 117 -> 245 Mbit/s. --- docs/reference/class/network.rst | 13 +++ examples/device/net_lwip_webserver/README.md | 2 +- .../device/net_lwip_webserver/src/lwipopts.h | 2 + examples/device/net_lwip_webserver/src/main.c | 75 +++++++++++++++-- src/class/net/ncm_device.c | 83 ++++++++++++++++++- src/class/net/net_device.h | 10 +++ 6 files changed, 175 insertions(+), 10 deletions(-) diff --git a/docs/reference/class/network.rst b/docs/reference/class/network.rst index 8868ec756f..fcf4256403 100644 --- a/docs/reference/class/network.rst +++ b/docs/reference/class/network.rst @@ -81,6 +81,16 @@ ECM/RNDIS drops the frame and renews reception itself; NCM keeps the frame and offers it again on the next ``tud_network_recv_renew()``, which the application must call. +NCM can also keep the frame in place instead (zero-copy): call +``tud_network_recv_hold()`` inside the callback and pass its handle to +``tud_network_recv_release()`` once the frame is no longer referenced; the +receive buffer stays valid until then. It returns ``NULL`` when holding the +frame could leave no buffer to receive into, every other one being held +already; copy the frame in that case. At least one buffer thus keeps cycling +while the network stack holds frames, and with ``CFG_TUD_NCM_OUT_NTB_N`` = 1 it +always returns ``NULL``. ``net_lwip_webserver`` uses it on high-throughput MCUs, +handing lwIP ``PBUF_REF`` pbufs. + For device-to-host frames: 1. Call ``tud_network_can_xmit(size)``. @@ -108,6 +118,9 @@ interface. * - ``tud_network_recv_renew()`` - Lets the driver deliver the next frame (re-arms reception, or offers the next NCM datagram). + * - ``tud_network_recv_hold()`` / ``tud_network_recv_release()`` + - NCM only: keep the frame offered to ``tud_network_recv_cb()`` in its + receive buffer until released (zero-copy), or ``NULL`` to copy it. * - ``tud_network_can_xmit()`` / ``tud_network_xmit()`` - Checks whether one frame of ``size`` bytes can be queued now, then queues it. Call ``xmit`` once after each successful check. diff --git a/examples/device/net_lwip_webserver/README.md b/examples/device/net_lwip_webserver/README.md index dfcb738568..309a5798ec 100644 --- a/examples/device/net_lwip_webserver/README.md +++ b/examples/device/net_lwip_webserver/README.md @@ -7,7 +7,7 @@ A USB virtual network adapter that runs a small lwIP stack on the device, servin - Brings up a USB network interface. Depending on the target MCU the build is either CDC-NCM (the default for most MCUs) or a dual RNDIS + CDC-ECM device that offers two configurations and lets the host pick its preferred one (Windows uses RNDIS, macOS uses CDC-ECM, Linux works with either). The NCM build also ships a BOS / Microsoft OS 2.0 descriptor so Windows auto-loads its NCM driver. - The device takes IP address 192.168.7.1 and runs a DHCP server (handing out 192.168.7.2–192.168.7.4) and an HTTP server. - Pressing the board button toggles the network link state (up/down) to simulate an Ethernet cable being unplugged/plugged. -- On higher-RAM MCUs it also starts an iperf TCP server for throughput testing. +- On higher-RAM MCUs it also starts an iperf TCP server for throughput testing, and the NCM build hands received frames to lwIP in place instead of copying them (`tud_network_recv_hold()`). - Blinks the board LED to indicate USB state (not mounted / mounted / suspended). ## Requirements diff --git a/examples/device/net_lwip_webserver/src/lwipopts.h b/examples/device/net_lwip_webserver/src/lwipopts.h index a1c9db8667..cd327d63a5 100644 --- a/examples/device/net_lwip_webserver/src/lwipopts.h +++ b/examples/device/net_lwip_webserver/src/lwipopts.h @@ -67,6 +67,8 @@ #define MEM_SIZE (TCP_SND_BUF + 2 * TCP_MSS) // 32-bit word checksum, the default 16-bit one costs about a quarter of the CPU at HS throughput #define LWIP_CHKSUM_ALGORITHM 3 + // NCM hands received frames to lwIP in place (PBUF_REF custom pbufs) instead of copying them + #define LWIP_SUPPORT_CUSTOM_PBUF 1 #else #define TCP_WND (4 * TCP_MSS) #define PBUF_POOL_SIZE 4 diff --git a/examples/device/net_lwip_webserver/src/main.c b/examples/device/net_lwip_webserver/src/main.c index 93e93e82eb..54ec4aeaf2 100644 --- a/examples/device/net_lwip_webserver/src/main.c +++ b/examples/device/net_lwip_webserver/src/main.c @@ -51,6 +51,9 @@ try changing the first byte of tud_network_mac_address[] below from 0x02 to 0x00 #include "bsp/board_api.h" #include "tusb.h" +#if CFG_TUD_NCM + #include "class/net/ncm.h" // NTB counts sizing the zero-copy pbuf pool +#endif #include "dhserver.h" #include "httpd.h" @@ -166,10 +169,66 @@ static void usbnet_netif_link_callback(struct netif *netif) { tud_network_link_state(BOARD_TUD_RHPORT, link_up); } +#if CFG_TUD_NCM && LWIP_HIGH_THROUGHPUT +// Zero-copy reception: a received frame is handed to lwIP in place, as a PBUF_REF pbuf pointing into the NCM receive +// buffer, which the class keeps until lwIP frees the pbuf. A frame the class cannot hold, or beyond this pool, is +// copied into a PBUF_POOL pbuf instead. +// One pbuf per held frame: every receive NTB can hold frames at once, each up to the datagram count the device +// advertises to the host per NTB. +#define RX_PBUF_N (CFG_TUD_NCM_OUT_NTB_N * CFG_TUD_NCM_OUT_MAX_DATAGRAMS_PER_NTB) +TU_VERIFY_STATIC(RX_PBUF_N <= UINT8_MAX, "rx_pbuf_free_count is 8-bit"); + +typedef struct { + struct pbuf_custom pc; // first: lwIP frees it through its struct pbuf + void *ntb; +} rx_pbuf_t; + +static rx_pbuf_t rx_pbuf[RX_PBUF_N]; +static rx_pbuf_t *rx_pbuf_free[RX_PBUF_N]; +static uint8_t rx_pbuf_free_count; + +static void rx_pbuf_init(void) { + for (uint8_t i = 0; i < RX_PBUF_N; i++) { + rx_pbuf_free[i] = &rx_pbuf[i]; + } + rx_pbuf_free_count = RX_PBUF_N; +} + +static void rx_pbuf_release(struct pbuf *p) { + rx_pbuf_t *rp = (rx_pbuf_t *) p; + tud_network_recv_release(rp->ntb); + rx_pbuf_free[rx_pbuf_free_count++] = rp; +} + +// a pbuf referencing the frame in place, or NULL when it must be copied +static struct pbuf *rx_pbuf_in_place(const uint8_t *src, uint16_t size) { + if (rx_pbuf_free_count == 0) { + return NULL; + } + void *ntb = tud_network_recv_hold(); + if (ntb == NULL) { + return NULL; + } + rx_pbuf_t *rp = rx_pbuf_free[--rx_pbuf_free_count]; + rp->ntb = ntb; + rp->pc.custom_free_function = rx_pbuf_release; + return pbuf_alloced_custom(PBUF_RAW, size, PBUF_REF, &rp->pc, (void *) (uintptr_t) src, size); +} +#else +static void rx_pbuf_init(void) {} + +static struct pbuf *rx_pbuf_in_place(const uint8_t *src, uint16_t size) { + (void) src; + (void) size; + return NULL; +} +#endif + static void init_lwip(void) { struct netif *netif = &netif_data; lwip_init(); + rx_pbuf_init(); /* the lwip virtual MAC address must be different from the host's; to ensure this, we toggle the LSbit */ netif->hwaddr_len = sizeof(tud_network_mac_address); @@ -191,20 +250,24 @@ static void init_lwip(void) { #endif } + bool tud_network_recv_cb(const uint8_t *src, uint16_t size) { struct netif *netif = &netif_data; if (size) { - struct pbuf *p = pbuf_alloc(PBUF_RAW, size, PBUF_POOL); + struct pbuf *p = rx_pbuf_in_place(src, size); if (p == NULL) { - printf("ERROR: Failed to allocate pbuf of size %d\n", size); - return false; + p = pbuf_alloc(PBUF_RAW, size, PBUF_POOL); + if (p == NULL) { + printf("ERROR: Failed to allocate pbuf of size %d\n", size); + return false; + } + + /* Copy buf to pbuf */ + pbuf_take(p, src, size); } - /* Copy buf to pbuf */ - pbuf_take(p, src, size); - // Surrender ownership of our pbuf unless there was an error // Only call pbuf_free if not Ok else it will panic with "pbuf_free: p->ref > 0" // or steal it from whatever took ownership of it with undefined consequences. diff --git a/src/class/net/ncm_device.c b/src/class/net/ncm_device.c index afafe42f2d..257b0fd0ba 100644 --- a/src/class/net/ncm_device.c +++ b/src/class/net/ncm_device.c @@ -129,6 +129,13 @@ typedef struct { static ncm_interface_t ncm_interface; CFG_TUD_MEM_SECTION static ncm_epbuf_t ncm_epbuf; +// Receive NTBs whose datagrams the glue logic still references after tud_network_recv_cb() (zero-copy). Kept apart +// from ncm_interface, which netd_init() clears: a reset must not recycle an NTB the network stack still points into. +static struct { + uint8_t count[RECV_NTB_N]; // tud_network_recv_hold() calls not yet released + bool done[RECV_NTB_N]; // all datagrams handed out: the last release returns the NTB to the free list +} ncm_recv_hold; + //--------------------------------------------------------------------+ // Weak stubs: invoked if no strong implementation is available //--------------------------------------------------------------------+ @@ -511,6 +518,18 @@ static recv_ntb_t *recv_get_next_ready_ntb(void) { #endif } // recv_get_next_ready_ntb +/** + * Index of a receive NTB in ncm_epbuf.recv[], or -1. + */ +static int recv_ntb_index(const recv_ntb_t *ntb) { + for (int i = 0; i < RECV_NTB_N; ++i) { + if (ntb == &ncm_epbuf.recv[i].ntb) { + return i; + } + } + return -1; +} + /** * Put NTB into the receiver free list. */ @@ -707,8 +726,13 @@ static void recv_transfer_datagram_to_glue_logic(void) { // -> next datagram ++ncm_interface.recv_glue_ntb_datagram_ndx; } else { - // end of datagrams reached - recv_put_ntb_into_free_list(ncm_interface.recv_glue_ntb); + // end of datagrams reached: free the NTB now, or on the last release of a held datagram + const int idx = recv_ntb_index(ncm_interface.recv_glue_ntb); + if (idx >= 0 && ncm_recv_hold.count[idx] != 0) { + ncm_recv_hold.done[idx] = true; + } else { + recv_put_ntb_into_free_list(ncm_interface.recv_glue_ntb); + } ncm_interface.recv_glue_ntb = NULL; } } @@ -800,6 +824,53 @@ void tud_network_recv_renew(void) { recv_try_to_start_new_reception(ncm_interface.rhport); } // tud_network_recv_renew +/** + * Keep the NTB holding the datagram passed to tud_network_recv_cb() allocated after the callback returns, so the + * datagram can be used in place. Call it from tud_network_recv_cb() only. + * + * Returns NULL, and the datagram must be copied, when holding it could leave no NTB to receive into: every other NTB + * is already held. At least one NTB thus always returns to the free list, even while the network stack keeps + * datagrams (out-of-order TCP segments, IP fragments), so reception cannot deadlock. With one receive NTB it always + * returns NULL. + */ +void *tud_network_recv_hold(void) { + const int idx = recv_ntb_index(ncm_interface.recv_glue_ntb); + if (idx < 0 || ncm_recv_hold.count[idx] == UINT8_MAX) { + return NULL; + } + + int held = 0; + for (int i = 0; i < RECV_NTB_N; ++i) { + if (i != idx && ncm_recv_hold.count[i] != 0) { + ++held; + } + } + if (held >= RECV_NTB_N - 1) { + return NULL; + } + + ncm_recv_hold.count[idx]++; + return ncm_interface.recv_glue_ntb; +} // tud_network_recv_hold + +/** + * Release a hold taken by tud_network_recv_hold(). Once all datagrams of the NTB were handed out and its last hold is + * released, the NTB returns to the free list. Task context only. + */ +void tud_network_recv_release(void *handle) { + const int idx = recv_ntb_index((const recv_ntb_t *) handle); + if (idx < 0 || ncm_recv_hold.count[idx] == 0) { + TU_LOG_DRV("(EE) tud_network_recv_release: not held\n"); + return; + } + + if (--ncm_recv_hold.count[idx] == 0 && ncm_recv_hold.done[idx]) { + ncm_recv_hold.done[idx] = false; + recv_put_ntb_into_free_list((recv_ntb_t *) handle); + recv_try_to_start_new_reception(ncm_interface.rhport); + } +} // tud_network_recv_release + /** * Same as tud_network_recv_renew() but knows \a rhport */ @@ -880,8 +951,14 @@ void netd_init(void) { for (int i = 0; i < XMIT_NTB_N; ++i) { ncm_interface.xmit_free_ntb[i] = &ncm_epbuf.xmit[i].ntb; } + int nfree = 0; for (int i = 0; i < RECV_NTB_N; ++i) { - ncm_interface.recv_free_ntb[i] = &ncm_epbuf.recv[i].ntb; + if (ncm_recv_hold.count[i] != 0) { + // still referenced by the network stack: freed by its last tud_network_recv_release() + ncm_recv_hold.done[i] = true; + } else { + ncm_interface.recv_free_ntb[nfree++] = &ncm_epbuf.recv[i].ntb; + } } ncm_interface.link_is_up = tud_network_default_link_state_cb(); } // netd_init diff --git a/src/class/net/net_device.h b/src/class/net/net_device.h index 9d0a3b9231..6f344aa94f 100644 --- a/src/class/net/net_device.h +++ b/src/class/net/net_device.h @@ -63,6 +63,16 @@ extern void rndis_class_set_handler(uint8_t *data, int size); // indicate to network driver that client has finished with the packet provided to network_recv_cb() void tud_network_recv_renew(void); +#if CFG_TUD_NCM +// Zero-copy reception: called from tud_network_recv_cb(), keeps the packet's buffer valid after the callback returns +// until tud_network_recv_release(). Returns NULL when the packet must be copied instead (holding it could leave no +// receive buffer, always so with CFG_TUD_NCM_OUT_NTB_N == 1). +void *tud_network_recv_hold(void); + +// release a buffer kept by tud_network_recv_hold(), task context only +void tud_network_recv_release(void *handle); +#endif + // poll network driver for its ability to accept another packet to transmit bool tud_network_can_xmit(uint16_t size); From 3d5540de36e60a964a9c0fddd134627a4575394e Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Wed, 11 Nov 2026 23:49:12 +0100 Subject: [PATCH 09/10] net_lwip_webserver: skip receive checksum verification The example's only link is USB, whose CRC already protects every packet. Re-verifying the IP/TCP/UDP/ICMP checksums of received frames cost about a quarter of the CPU at HS throughput. Outgoing checksums are still generated. stm32u5a5nucleo, host->device iperf: slave 165 -> 208 Mbit/s, DMA 246 -> 261-267 Mbit/s (the CPU then idles: link-bound). --- examples/device/net_lwip_webserver/src/lwipopts.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/device/net_lwip_webserver/src/lwipopts.h b/examples/device/net_lwip_webserver/src/lwipopts.h index cd327d63a5..b98244a2c1 100644 --- a/examples/device/net_lwip_webserver/src/lwipopts.h +++ b/examples/device/net_lwip_webserver/src/lwipopts.h @@ -55,6 +55,13 @@ #define ETH_PAD_SIZE 0 #define LWIP_IP_ACCEPT_UDP_PORT(p) ((p) == PP_NTOHS(67)) +// The only link is USB, whose CRC already protects every packet, so received frames are not re-verified: that +// checksum costs about a quarter of the CPU at HS throughput. Outgoing checksums are still generated for the host. +#define CHECKSUM_CHECK_IP 0 +#define CHECKSUM_CHECK_UDP 0 +#define CHECKSUM_CHECK_TCP 0 +#define CHECKSUM_CHECK_ICMP 0 + #define TCP_MSS (1500 /*mtu*/ - 20 /*iphdr*/ - 20 /*tcphhr*/) #define TCP_SND_BUF (4 * TCP_MSS) #if LWIP_HIGH_THROUGHPUT From acd4f0d350608f42889c876513509b310a9725c2 Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Thu, 12 Nov 2026 03:57:32 +0100 Subject: [PATCH 10/10] ncm: stop reception on deinit, assert on an unheld release - netd_deinit() clears itf_data_alt, so a tud_network_recv_release() after tud_deinit() returns its NTB to the free list without starting a transfer on the deinitialized controller. - tud_network_recv_release() asserts on an unknown or unheld handle instead of only logging it. - docs: tud_network_recv_release() is task-context only, like renew and xmit. --- docs/reference/class/network.rst | 5 +++-- src/class/net/ncm_device.c | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/reference/class/network.rst b/docs/reference/class/network.rst index fcf4256403..feb5e3b31c 100644 --- a/docs/reference/class/network.rst +++ b/docs/reference/class/network.rst @@ -99,8 +99,9 @@ For device-to-host frames: Ethernet frame into ``dst`` and return its length. The drivers do not lock their state: call ``tud_network_recv_renew()``, -``tud_network_can_xmit()`` and ``tud_network_xmit()`` from the task that runs -``tud_task()``. +``tud_network_recv_release()``, ``tud_network_can_xmit()`` and +``tud_network_xmit()`` from the task that runs ``tud_task()``, not from an ISR +or another task. Use ``tud_network_link_state()`` to notify the host when the logical or physical link changes. A mounted USB device is not necessarily a link-up network diff --git a/src/class/net/ncm_device.c b/src/class/net/ncm_device.c index 257b0fd0ba..aef8614836 100644 --- a/src/class/net/ncm_device.c +++ b/src/class/net/ncm_device.c @@ -859,10 +859,7 @@ void *tud_network_recv_hold(void) { */ void tud_network_recv_release(void *handle) { const int idx = recv_ntb_index((const recv_ntb_t *) handle); - if (idx < 0 || ncm_recv_hold.count[idx] == 0) { - TU_LOG_DRV("(EE) tud_network_recv_release: not held\n"); - return; - } + TU_ASSERT(idx >= 0 && ncm_recv_hold.count[idx] != 0,); if (--ncm_recv_hold.count[idx] == 0 && ncm_recv_hold.done[idx]) { ncm_recv_hold.done[idx] = false; @@ -967,6 +964,9 @@ void netd_init(void) { * Deinit driver */ bool netd_deinit(void) { + // Stop reception: a tud_network_recv_release() after tud_deinit() still returns its NTB to the free list, but must + // not start a transfer on the deinitialized controller. Holds are kept for netd_init(). + ncm_interface.itf_data_alt = 0; return true; }