Skip to content

host: USB/IP-over-Ethernet bridge example, plus DWC2 / control_xfer fixes#3637

Open
andrewleech wants to merge 6 commits into
hathach:masterfrom
andrewleech:r27-usbipd-example
Open

host: USB/IP-over-Ethernet bridge example, plus DWC2 / control_xfer fixes#3637
andrewleech wants to merge 6 commits into
hathach:masterfrom
andrewleech:r27-usbipd-example

Conversation

@andrewleech
Copy link
Copy Markdown
Contributor

Summary

Adds examples/host/usbipd, a USB/IP-over-Ethernet host bridge: a TinyUSB host that forwards URBs from an attached USB device over TCP/IP to a Linux usbip client. Once attached, the device shows up in the kernel's USB bus the same way a locally plugged one would (cdc-acm becomes /dev/ttyACM<N>, HID becomes an evdev node, etc).

The bridge core (usbip_server.c) is portable C against the public tuh_* and lwIP tcp_* APIs, so it should work on any TinyUSB host port with a TCP-capable lwIP stack. The board-specific glue is just the lwIP netif driver and PHY init; the included ethernetif.c plus the LAN8742 driver target the on-board RMII PHY of the STM32F429ZI / F439ZI Nucleo-144. Other boards bring their own.

Tested on STM32F439ZI Nucleo-144 with the on-board LAN8742A: full mpremote round-trip through the bridge to a MicroPython Pico W, plus a debugprobe cdc-acm device.

The PR also carries four DWC2/host-stack fixes the example exposed end-to-end. Without them, multi-packet bulk-OUT corrupts data and cdc-acm cleanup wedges on first close. The fixes are scoped tightly and useful on their own; the example is just the regression reproducer.

The first fix is a cherry-pick of @HiFiPhile's commit 17d24b39 from #3632. We landed on the exact same diff independently while bringing this up, so cherry-picking keeps attribution clean and means git rebase silently skips it once #3632 merges. Including it directly keeps the PR self-contained for reviewers running the example.

What's in here

6 commits, bottom-up:

  1. hcd/dwc2: fix txfifo full check (cherry-picked from @HiFiPhile's hcd/dwc2: fix txfifo full check #3632) handle_txfifo_empty cached fifo_available / req_queue_available once before the per-channel loop; after writing one packet the cached values were stale, so multi-packet bulk-OUT could be issued without enough FIFO space and XFER_COMPLETE never fired. Re-read inside the loop. Without this, mpremote fs cp of a 3.7 KB file through the bridge fails with corrupted bulk-OUT data; with it, the same fs cp round-trips cleanly in ~3 s. Same hunk also fixes USB HOST MSC hangs on write10 #3623.

  2. hcd/dwc2: save post-transfer PID in DMA-mode IN handler handle_channel_in_slave saves hctsiz.pid into edpt->next_pid after XFER_COMPLETE; the DMA-mode IN handler did not. On a short packet ending a multi-packet IN early, the next URB started with the toggle pre-computed in channel_xfer_start from the requested packet count rather than the actual post-transfer PID, triggering DATATOGGLE_ERR on the next IN. Hardware either retried (dropping the device's first packet) or coalesced the duplicate (delivering corrupt bytes). OUT is exempt because the host drives the toggle on OUT.

  3. hcd/dwc2: fire xfer_complete callback after hcd_edpt_abort_xfer The function disabled the active channel without guaranteeing an hcd_event_xfer_complete for the in-flight transfer. The channel-halted IRQ landed in handle_channel_*_dma/slave with no XFER_COMPLETE / STALL / BABBLE / XACT_ERR set, so is_done stayed false and the natural callback never fired. Callers needing a giveback after abort had to synthesise one externally.

    Mark the channel as aborted before disabling, then in handle_channel_irq force is_done with XFER_RESULT_FAILED when the abort flag is set and the channel is no longer enabled (distinguishes a real abort halt from a NAK retry that re-armed the channel). Also tightens the contract: returns false when no in-flight transfer was found, was previously unconditional true. The new cross-port contract is documented in src/host/hcd.h. Other ports (max3421e, rp2040, ehci, ohci, wch, musb, rusb2) haven't been audited against it yet, flagged for follow-up; existing implementations are unchanged here.

  4. host: honour tuh_xfer_t.timeout_ms in tuh_control_xfer The timeout_ms field was a placeholder ("not supported yet"). The synchronous tuh_control_xfer path (complete_cb == NULL) was a busy loop with no timeout, so any device that NAK'd the control transfer indefinitely (e.g. cdc-acm on CLEAR_FEATURE(ENDPOINT_HALT)) wedged the caller. timeout_ms == 0 keeps the historical wait-forever default for callers that don't initialise the field, so this is backwards-compatible. Non-zero returns false with xfer->result = XFER_RESULT_FAILED and aborts the EP0 channel via hcd_edpt_abort_xfer so the next control transfer on the same daddr can proceed. Relies on commit (3) for clean abort. cdc_host and hid_host switch to designated init for their fake tuh_xfer_t so the new field zero-initialises rather than holding stack garbage.

  5. hw/bsp/stm32f4: add optional Ethernet support and LAN8742 PHY driver Opt-in family_add_eth(TARGET) helper for the F4 BSP, mirroring the existing family_add_tinyusb / family_add_rtos / family_add_uf2. When called from an example's CMakeLists it pulls in stm32f4xx_hal_eth.c, the LAN8742 driver, and the per-board board_eth.c (RMII pin map for HAL_ETH_MspInit); without the call, F4 boards build identically to before. The LAN8742 driver lives at hw/mcu/st/stm32_lan8742/ (vendored from STMicroelectronics/stm32-lan8742, matches the existing cmsis_device_f4 / stm32f4xx_hal_driver layout). boards/stm32f439nucleo/board_eth.c carries the F429ZI / F439ZI pin map. Verified examples/host/cdc_msc_hid builds clean at this commit, no ETH symbols pulled in.

  6. examples/host/usbipd: add USB/IP-over-Ethernet host bridge The bridge itself. Single-client lwIP raw-API server on TCP/3240 implementing the kernel-compatible USB/IP wire format: OP_REQ_DEVLIST, OP_REQ_IMPORT, CMD_SUBMIT (control + bulk + interrupt, IN/OUT), CMD_UNLINK. Endpoints are opened lazily via tuh_edpt_open with the descriptor sniffed from the kernel's GET_DESCRIPTOR(CONFIG) reply that flows through us, avoiding a sync descriptor fetch from inside the lwIP recv path. Includes a per-EP submit queue: cdc-acm queues 16 read-ahead URBs on its bulk-IN but TinyUSB allows only one URB per EP in flight, so the rejected ones get queued and drained from the completion callback when the EP frees.

    Vendored alongside the example (BSD-3-Clause, captured in src/LICENSE.stm32-vendored): ethernetif.{c,h} and lwipopts.h from STM32CubeF4 LwIP_TCP_Echo_Server with the DP83848 to LAN8742 PHY swap (same _Object_t / _IOCtx_t / _Init / _GetLinkState API); arch/cc.h reused from the existing net_lwip_webserver example.

Hardware

  • STM32F429ZI or STM32F439ZI Nucleo-144 (existing BSPs in hw/bsp/stm32f4/boards/).
  • USB device under test plugged into the OTG_FS host port (CN13 USER USB on the Nucleo-144). VBUS is enabled by the BSP via PG6 in board_init_after_tusb().
  • Ethernet via the on-board LAN8742A connected by RMII.
  • STLink VCOM for the example's diagnostic UART (USART3 on PD8/PD9).

Build

cd examples/host/usbipd
mkdir build && cd build
cmake -G Ninja -DBOARD=stm32f439nucleo ..
ninja

54 KB FLASH, 93 KB RAM (the inflight buffer pool dominates: 32 slots x 1.5 KB).

Verification

Tested on STM32F439ZI Nucleo-144 (S/N 066CFF495177514867213407) with two devices on OTG_FS:

  • Raspberry Pi Pico Debugprobe firmware (vid 2e8a:000c): usbip list -r <ip> returns the device, sudo usbip attach -r <ip> -b 1-1 enumerates and creates /dev/ttyACM<N>, pyserial open + write + read + close round-trips clean. Detach via echo 0 > /sys/devices/platform/vhci_hcd.0/detach is clean (three RET_UNLINK status=0 messages, no rejected cascade).

  • Raspberry Pi Pico W with MicroPython firmware (vid 2e8a:0005): full mpremote round-trip in a single chained process:

    $ mpremote connect /dev/ttyACM12 \ exec "import sys, os; print(sys.platform, sys.version); print(os.uname())" \ + fs cp /tmp/test_script.py :test_script.py \ + fs ls \ + exec "exec(open('test_script.py').read())" \ + fs rm :test_script.py rp2 3.4.0; MicroPython v1.28.0-preview.147 ... (sysname='rp2', ..., machine='Raspberry Pi Pico W with RP2040') cp /tmp/test_script.py :test_script.py ls : ... 147 test_script.py ... hello from a script over usbip rm :test_script.py

To reproduce the failing baseline against stock upstream, revert the four fix commits and rebuild. The cdc-acm probe wedges instead of producing /dev/ttyACM<N>:

usbip: SUBMIT seq=185 ep=0x83: tuh_*_xfer rejected
usbip: SUBMIT seq=186 ep=0x83: tuh_*_xfer rejected
...
usbip: UNLINK seq=201 (cancels seq=184 ep=0x83)
usbip: SUBMIT seq=206 ep=0x83: tuh_*_xfer rejected   # claim never released

With the fixes applied, the same scenario gives one initial silent SUBMIT, drained queue entries, then a clean detach with three RET_UNLINK status=0 messages.

Notes for reviewers

  • If the usbipd example feels too big or not a great fit for the examples tree, happy to publish it as a standalone project and drop commits 5 and 6 from this PR. The example was as much the reproducer that pinned down the host-stack issues in commits 1-4 as a contribution in its own right, so the four fixes can stand alone.

  • Each fix commit is self-contained and can be cherry-picked individually. The timeout_ms commit relies on the abort_xfer commit for clean EP0 abort on timeout recovery; if the abort fix lands first the timeout fix is still a strict improvement on ports that don't yet honour the new abort contract (the timeout still fires, the recovery is just less complete).

  • The Ethernet support is split into a reusable BSP helper (commit 5) and the example (commit 6) so other F4 examples can use the same family_add_eth opt-in. Boards without on-board Ethernet are unaffected; family_add_eth fails loudly at CMake time if the board's board_eth.c is missing rather than silently producing a non-functional binary.

  • The example is gated to board:stm32f439nucleo via only.txt because runtime needs a board with both the LAN8742A RMII pin map and a working OTG_FS host port. Adding other F4 boards is just a new board_eth.c plus extending only.txt, the bridge core is portable.

  • The aborted flag in hcd_xfer_t is a :1 bitfield that fits in the existing flags byte, no growth in _hcd_data.

Generative AI

I used generative AI tools when creating this PR, but a human has checked the code and is responsible for the code and the description above.

HiFiPhile and others added 6 commits May 10, 2026 16:43
Signed-off-by: HiFiPhile <admin@hifiphile.com>
handle_channel_in_slave saves hctsiz.pid into edpt->next_pid after
XFER_COMPLETE; the DMA-mode IN handler was missing the save. On a
short packet ending a multi-packet IN early, the next URB started
with the toggle pre-computed in channel_xfer_start rather than the
authoritative post-transfer PID, triggering DATATOGGLE_ERR. OUT
direction is exempt: the host drives the PID toggle on OUT.

Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
hcd_edpt_abort_xfer disabled the channel without guaranteeing an
hcd_event_xfer_complete for the in-flight transfer; the channel-halted
IRQ landed in handle_channel_*_dma/slave with no XFER_COMPLETE / STALL
/ BABBLE / XACT_ERR set, so is_done stayed false and no callback fired.
Callers needing a giveback after abort had to synthesise one.

Mark the channel aborted before disabling, then in handle_channel_irq
force is_done with XFER_RESULT_FAILED when the abort flag is set and
the channel is no longer enabled (distinguishes a true abort halt from
a NAK retry that re-armed the channel). The function now also returns
false when there is no in-flight transfer.

Document the cross-port callback contract in src/host/hcd.h.

Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
The field was a placeholder ("not supported yet"); the synchronous
tuh_control_xfer path (complete_cb == NULL) was a busy loop with no
timeout, so a device that NAK'd a control transfer indefinitely wedged
the caller. timeout_ms == 0 keeps the historical wait-forever default;
non-zero returns false with xfer->result = XFER_RESULT_FAILED on
timeout and aborts the EP0 channel via hcd_edpt_abort_xfer so a
subsequent control transfer on the same daddr can proceed.

cdc_host / hid_host switch to designated init for their fake tuh_xfer_t
so the new field is zero-initialised rather than stack garbage.

Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
Vendors github.com/STMicroelectronics/stm32-lan8742 under
hw/mcu/st/stm32_lan8742 (BSD-3-Clause, see LICENSE.md). Adds
family_add_eth(TARGET) to hw/bsp/stm32f4/family.cmake which sets
HAL_ETH_MODULE_ENABLED, pulls in stm32f4xx_hal_eth.c plus the
LAN8742 driver, and the per-board MspInit (RMII pin map). Boards
that wire an Ethernet PHY supply boards/<board>/board_eth.c with
HAL_ETH_MspInit; family_add_eth fails if it's missing.

Provides board_eth.c for stm32f439nucleo (the only F4 board in
the tree with an on-board PHY). Other F4 boards remain unchanged
- the helper is opt-in.

The BSP's existing stm32f4xx_hal_conf.h already has the matching
ETH constants (LAN8742A_PHY_ADDRESS, MAC_ADDR0..5, ETH_RX_BUF_SIZE,
PHY_BCR ...); they were only missing a way to actually compile
HAL_ETH and a driver to talk to the PHY. This is the consumer for
those constants.

Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
A TinyUSB host that forwards URBs from a USB device on its OTG host
port over TCP/IP to a Linux usbip client. Once attached, the device
appears in the kernel's USB bus the same way a locally plugged device
would: cdc-acm becomes /dev/ttyACM<N>, HID becomes an evdev node,
and so on.

Targets STM32F439ZI Nucleo-144 (only F4 board in the tree with an
on-board Ethernet PHY). Uses family_add_eth() from the F4 BSP for
HAL_ETH and the LAN8742 driver; the example carries only the lwIP
netif/PHY-IO glue and the USB/IP server. Per-EP submit queue
serialises cdc-acm's 16-deep bulk-IN read-ahead against TinyUSB's
one-URB-per-EP constraint.

Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
@HiFiPhile
Copy link
Copy Markdown
Collaborator

Hi, it looks very nice !

Just for the example I think it's better to include a host/device dual port approach with NCM device + USBIP host, in this way it can be tested on more MCUs. F439 is a very old MCU which I don't have, I don't know if Ha Thach has it but it's not in the HIL test farm anyway.

PS: I'm thinking about adding a Awesome projects section to readme for external projects which are not trivial to maintain.

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.

USB HOST MSC hangs on write10

3 participants