← Blog

Zephyr USB CDC NCM investigation

On 26 October we described a lock-up in the Zephyr USB network driver and offered a two-line fix, pull request #123515. The Zephyr project merged it on 29 October. This post shows the evidence in detail. We tested Zephyr as we found it and with each fix, on three real boards and six emulated CPUs. We also found the same defect in the second USB network driver, and some smaller problems.

Summary

Starting point

ItemValue
Zephyrmain branch, commit 5d2ed837a8c (29 October 2026, version v4.5.0-rc3-671)
ToolchainZephyr SDK 1.0.1 (GCC 14.3)
PCUbuntu 24.04.5, Linux 7.0, kernel drivers cdc_ncm and cdc_ether

We built five variants from the same commit, so that only the driver code is different:

VariantDriverCodeWhen no receive packet is free
ACDC-NCMmain with #123515 removed (the state as we found it)waits without a time limit
BCDC-NCMmain (#123515 merged)drops; a temporary packet uses one receive packet
CCDC-NCMmain + "direct copy" patch (not yet submitted)drops; no temporary packet
ECDC-ECMmainwaits without a time limit
FCDC-ECMmain + ECM patch (not yet submitted)drops

Devices tested

DeviceCPUUSB device controller (Zephyr driver)Variants
Teensy 4.1NXP i.MX RT1062, Cortex-M7, 600 MHzhigh speed, udc_mcux_ehciA B C E F
STM32F3DISCOVERYSTM32F303VC, Cortex-M4F, 72 MHz, 40 KB RAMfull speed, udc_stm32A B C E F
Raspberry Pi PicoRP2040, Cortex-M0+, 125 MHzfull speed, udc_rpi_picoA B C E F
native_simx86 (the PC itself)virtual USB host and device controllerA B C
QEMUCortex-M3; RISC-V 32; and big-endian SPARC (LEON3), OpenRISC 1000, MIPS (Malta)virtual USB host and device controllerA B C

Test procedure

Emulated: a Zephyr test with a virtual USB host

Zephyr has a virtual USB host controller and a virtual USB device controller that connect inside one firmware image. Our test uses them: a USB host in the same image sends hand-built NCM transfers to the real CDC-NCM driver. The test then checks two things:

  1. All receive packets are in use and one transfer arrives. Does the USB thread wait? Does the device still answer a USB control request (GET_CONFIGURATION)?
  2. One transfer carries 6 datagrams, and only 4 receive packets are free. How many datagrams reach the application?

The same result on all six targets, little-endian and big-endian:

VariantTest 1: USB thread waitsTest 1: control request answeredTest 2: datagrams delivered with 4 free packets
Ayesno4, then the USB thread waits
Bnoyes3
Cnoyes4

Real boards: an application that stops reading its socket

A slow application is the simplest way to empty the receive packet pool. A UDP socket in Zephyr keeps every datagram that the application has not read yet, and each datagram holds one receive packet. Our test firmware does this in a fixed cycle:

The PC sends 200 UDP datagrams per second to the board. During each run:

Each run lasts 50 s, so it has two or three complete hold phases. We repeated the runs; the results did not change.

Results on the real boards

BoardVariantUSB thread waits during holdUSB control requests without answerPC TX watchdogDatagrams the socket could hold
Teensy 4.1A99.6 %55 of 220yes, every hold8 of 8, then the device stops
B0 %-no7 of 8
C0 %0 of 507no8 of 8
E (ECM)99.6 %-yes8 of 8, then the device stops
F (ECM)0 %-no8 of 8
STM32F3A99.8 %52 of 236yes, every hold4 of 4, then the device stops
B0 %-no3 of 4
C0 %0 of 505no4 of 4
E (ECM)99.8 %-yes4 of 4, then the device stops
F (ECM)0 %-no4 of 4
Raspberry Pi PicoA99.6 %49 of 254yes, every hold8 of 8, then the device stops
B0 %0 of 503no7 of 8
C0 %0 of 504no8 of 8
E (ECM)99.6 %48 of 260yes8 of 8, then the device stops
F (ECM)0 %0 of 504no8 of 8

"-": the run was made before the PC had permission to send the control requests. The remaining 0.2 to 0.4 % in the A rows is the time to fill the packet pool at the start of a hold phase. With variants A and E, the USB thread waited in every complete hold phase of every run.

Teensy 4.1, variants A, B, C: the PC transmit rate drops to zero during each hold phase with variant A, with TX watchdog events; B and C transmit at 200 datagrams per second throughout

STM32F3DISCOVERY, variants A, B, C: same pattern as the Teensy

Raspberry Pi Pico, variants A, B, C: same pattern as the Teensy

Teensy 4.1, CDC-ECM variants E and F: E stops during each hold phase, F does not

Each plot shows how many datagrams per second the PC sends to the board (blue). Gray bands are the hold phases, from the board's clock. Orange lines are the PC's TX watchdog events. With variant A, the PC stops sending soon after a hold phase starts, because the board no longer accepts USB transfers. When the hold phase ends, the backlog goes through in a short burst.

What the debugger shows

We stopped the STM32F3 with a debugger 8.2 s into a hold phase, with variant A. The USB thread was in this call chain:

usbd_thread()                       usbd_core.c:222
 cdc_ncm_acl_out_cb()                usbd_cdc_ncm.c:574   (K_FOREVER)
  net_pkt_rx_alloc_with_buffer()      net_pkt.c:1882
   k_mem_slab_alloc(&rx_pkts)          mem_slab.c:250
    z_swap()                            the thread waits

All 4 receive packets were in use, and the monitor thread had counted 7431 waiting samples of 7450 in that hold phase.

The CPU does not stop; the USB device does

Our first description said "lock-up". The measurements make this more exact:

Problems found, and their status

#ProblemEvidenceStatus
1CDC-NCM waits for a receive packet in the USB thread; the whole USB device stops.3 boards, 6 emulated CPUsFixed: #123515, merged 29 October 2026
2CDC-ECM has the same wait (K_FOREVER in cdc_ecm_acl_out_cb()).3 boardsOne-line fix prepared and tested; separate pull request to come
3After fix 1, CDC-NCM still uses a temporary packet for each transfer. It takes one receive packet from the pool and costs one more allocation for each transfer.The application can use one packet less (7 of 8, 3 of 4); emulated test: 3 instead of 4 datagramsPatch prepared (suggested by a Zephyr maintainer during the review of #123515): copy each datagram directly; 196 to 220 bytes less flash
4Under packet shortage, the drivers log one error line for each dropped datagram, with no rate limit (about 5000 lines in 35 s at 200 datagrams/s).On the STM32F3 this pushed lines of the application out of the console bufferOpen
5The default stack size of the USB device and USB host threads (1024 bytes) is too small on SPARC. The same test needs 1132 to 1180 bytes there, and 516 to 664 bytes on the other CPUs. With the default size, memory was corrupted without a message.QEMU LEON3, thread analyzerOpen; issue in preparation
6One run on emulated Cortex-M3 received a corrupt transfer from the virtual USB host and failed a reference-count assertion.1 occurrence; 0 in 6 repeatsOpen; needs a reproducer

One more observation is not a defect: when the pool is empty, the board also drops the PC's ARP requests, so the PC holds back its traffic until the address resolves. Every driver that drops packets has this effect.

Cost of the changes

The fixes add no RAM. Flash (code size, text) of the test firmware:

BoardABCEF
Teensy 4.1138,756138,752138,536137,572137,568
STM32F391,52091,51291,32490,39690,392
Raspberry Pi Pico92,00892,00091,80490,71290,704

The static RAM (data + bss) is the same for A, B and C. The gain of C is at run time: the application can use every receive packet.

Next


Made with a notepad.