From 0ef555c7da073623fd293fbcc64d582921a3198c Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Sat, 17 Oct 2026 15:27:49 +0200 Subject: [PATCH 1/4] wch/usbfs: hold IN endpoints during host-to-device control transfers on CH32V103/V20x On CH32V203 USBFS, an IN transaction completed on any other endpoint during a host-to-device control transfer is taken by the SIE as the status stage: EP0 then gets no handshake at all (not even NAK) until the next SETUP, although EP0's RX/TX control registers are armed correctly. The host abandons the data or status stage after three tries. cdc_msc_throughput hits it on every run because it streams bulk IN as soon as DTR rises, while the host still sends SET_LINE_CODING / SET_CONTROL_LINE_STATE (HIL: nanoch32v203-usbfs "CDC dd read failed"; on Windows the CDC port cannot even be opened). Traced with a USBPV sniffer and an on-target ISR trace: device-to-host requests and OUT traffic on other endpoints are unaffected, INT_BUSY busy-NAKs alone do not trigger it, and bulk data stays intact in both directions. CH32V305 USBFS takes the same sequences without losing EP0. From such a SETUP until its status stage completes (or EP0 stalls, or bus reset), hold the non-iso IN endpoints at NAK and re-arm the ones holding a packet afterwards. The hold is taken in the SETUP ISR before UIF_TRANSFER is cleared, so INT_BUSY covers the window. Enabled for CH32V103 (same HIL failure, intermittent) and CH32V20x only; other parts compile it out. --- src/portable/wch/ch32_usbfs_reg.h | 7 ++++ src/portable/wch/dcd_ch32_usbfs.c | 56 ++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/portable/wch/ch32_usbfs_reg.h b/src/portable/wch/ch32_usbfs_reg.h index e73a8b3ae2..45aba7893b 100644 --- a/src/portable/wch/ch32_usbfs_reg.h +++ b/src/portable/wch/ch32_usbfs_reg.h @@ -194,6 +194,13 @@ #define CH32_USBFS_EP3_1023_BUFSIZE 1 #endif +// CH32V103/V20x USBFS lose EP0 when an IN completes on another endpoint during a host-to-device control +// transfer (see ep_in_hold() in dcd_ch32_usbfs.c). Traced on CH32V203; CH32V103 fails the same HIL test. +// CH32V305 USBFS was checked unaffected; X035/CH58x/F20x are untested. +#if CFG_TUSB_MCU == OPT_MCU_CH32V103 || CFG_TUSB_MCU == OPT_MCU_CH32V20X + #define CH32_USBFS_H2D_IN_HOLD 1 +#endif + #ifdef __GNUC__ #pragma GCC diagnostic pop #endif diff --git a/src/portable/wch/dcd_ch32_usbfs.c b/src/portable/wch/dcd_ch32_usbfs.c index 4762e682df..915bb8484b 100644 --- a/src/portable/wch/dcd_ch32_usbfs.c +++ b/src/portable/wch/dcd_ch32_usbfs.c @@ -98,6 +98,9 @@ static inline void ep_tx_set_response(uint8_t ep, uint8_t res) { EP_CTRL(ep) = (uint8_t) ((EP_CTRL(ep) & ~USBFS_EPC_T_RES_MASK) | (res & USBFS_EP_T_RES_MASK)); } + static inline uint8_t ep_tx_get_response(uint8_t ep) { + return (uint8_t) (EP_CTRL(ep) & USBFS_EPC_T_RES_MASK); + } static inline void ep_rx_set_response(uint8_t ep, uint8_t res) { EP_CTRL(ep) = (uint8_t) ((EP_CTRL(ep) & ~USBFS_EPC_R_RES_MASK) | ((res & USBFS_EP_R_RES_MASK) << USBFS_EPC_R_RES_SHIFT)); } @@ -107,6 +110,9 @@ static inline void ep_tx_set_response(uint8_t ep, uint8_t res) { EP_TX_CTRL(ep) = (uint8_t) ((EP_TX_CTRL(ep) & ~USBFS_EP_T_RES_MASK) | res); } + static inline uint8_t ep_tx_get_response(uint8_t ep) { + return (uint8_t) (EP_TX_CTRL(ep) & USBFS_EP_T_RES_MASK); + } static inline void ep_rx_set_response(uint8_t ep, uint8_t res) { EP_RX_CTRL(ep) = (uint8_t) ((EP_RX_CTRL(ep) & ~USBFS_EP_R_RES_MASK) | res); } @@ -130,6 +136,9 @@ struct usb_xfer { static struct { bool ep0_tog; uint8_t ep0_status_dir; +#ifdef CH32_USBFS_H2D_IN_HOLD + bool in_hold; // non-EP0 IN endpoints held at NAK for a host-to-device control transfer +#endif bool isochronous[EP_MAX][2]; // per [ep][dir]: an ep number may be iso in one direction struct usb_xfer xfer[EP_MAX][2]; #ifdef CH32_USBFS_EP4_SHARES_EP0 @@ -226,6 +235,39 @@ static inline bool ep_shares_ep0_dma(uint8_t ep) { #endif /* private helpers */ + +#ifdef CH32_USBFS_H2D_IN_HOLD +// During a host-to-device control transfer the SIE takes an IN transaction completed on any other +// endpoint as the status stage, then ignores EP0 tokens (no handshake at all) until the next SETUP: +// the host times out the data or status stage (seen on CH32V203 with bulk IN streaming alongside +// CDC SET_LINE_CODING / SET_CONTROL_LINE_STATE). Hold the non-iso IN endpoints at NAK from such a +// SETUP until its status stage completes. OUT traffic and device-to-host transfers are unaffected. +static inline bool ep_in_held(void) { + return data.in_hold; +} + +static void ep_in_hold(bool hold) { + data.in_hold = hold; + for (uint8_t ep = 1; ep < EP_MAX; ep++) { + if (data.isochronous[ep][TUSB_DIR_IN]) { continue; } + const uint8_t res = ep_tx_get_response(ep); + if (hold && res == USBFS_EP_T_RES_ACK) { + ep_tx_set_response(ep, USBFS_EP_T_RES_NAK); + } else if (!hold && res == USBFS_EP_T_RES_NAK && data.xfer[ep][TUSB_DIR_IN].valid) { + ep_tx_set_response(ep, USBFS_EP_T_RES_ACK); // a packet is loaded: re-arm it (STALL stays) + } + } +} +#else +static inline bool ep_in_held(void) { + return false; +} + +static inline void ep_in_hold(bool hold) { + (void) hold; +} +#endif + static void update_in(uint8_t rhport, uint8_t ep, bool force) { struct usb_xfer *xfer = &data.xfer[ep][TUSB_DIR_IN]; if (xfer->valid) { @@ -247,12 +289,14 @@ static void update_in(uint8_t rhport, uint8_t ep, bool force) { } else if (data.isochronous[ep][TUSB_DIR_IN]) { ep_tx_set_response(ep, USBFS_EP_T_RES_NYET); } else { - ep_tx_set_response(ep, USBFS_EP_T_RES_ACK); + ep_tx_set_response(ep, ep_in_held() ? USBFS_EP_T_RES_NAK : USBFS_EP_T_RES_ACK); } } else { xfer->valid = false; if (ep == 0) { ep_tx_ctrl_set(0, USBFS_EP_T_RES_NAK | (data.ep0_tog ? USBFS_EP_T_TOG : 0)); + // the only EP0 IN of a host-to-device transfer is its status stage: release the hold + if (ep_in_held()) { ep_in_hold(false); } } else if (!data.isochronous[ep][TUSB_DIR_IN]) { ep_tx_set_response(ep, USBFS_EP_T_RES_NAK); } @@ -379,6 +423,8 @@ void dcd_int_handler(uint8_t rhport) { const tusb_control_request_t *setup = (const tusb_control_request_t *)ep0_out; data.ep0_status_dir = (setup->wLength && setup->bmRequestType_bit.direction == TUSB_DIR_IN) ? TUSB_DIR_OUT : TUSB_DIR_IN; + // Done before INT_FG is cleared below: INT_BUSY NAKs every token until then (see ep_in_hold) + ep_in_hold(setup->bmRequestType_bit.direction == TUSB_DIR_OUT); // The first transaction of either the data or status stage always uses DATA1. ep_rx_ctrl_set(0, ((setup->wLength == 0) ? USBFS_EP_R_RES_ACK : USBFS_EP_R_RES_NAK) | USBFS_EP_R_TOG); @@ -400,6 +446,7 @@ void dcd_int_handler(uint8_t rhport) { USBOTG_FS->DEV_ADDR = 0x00; ep_rx_ctrl_set(0, USBFS_EP_R_RES_ACK); + if (ep_in_held()) { ep_in_hold(false); } // drop the hold; reset_ep_ctrls() resets every response reset_ep_ctrls(); USBOTG_FS->INT_FG = USBFS_INT_FG_BUS_RST; @@ -568,6 +615,13 @@ void dcd_edpt_stall(uint8_t rhport, uint8_t ep_addr) { uint8_t ep = tu_edpt_number(ep_addr); uint8_t dir = tu_edpt_dir(ep_addr); if (ep == 0) { + // A stalled control transfer has no status stage to release the IN hold: release it before the + // host can see the STALL and start the next transfer, IRQ masked so no SETUP lands mid-release. + if (ep_in_held()) { + dcd_int_disable(rhport); + ep_in_hold(false); + dcd_int_enable(rhport); + } if (dir == TUSB_DIR_OUT) { ep_rx_ctrl_set(0, USBFS_EP_R_RES_STALL); } else { From 6494ce7a0ba8be5f121789d2154f2ebb886b5b0d Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Sat, 17 Oct 2026 16:05:32 +0200 Subject: [PATCH 2/4] wch/usbfs: re-arm only the IN endpoints the hold parked HIL device/mtp raw early_rejection failed on ch32v103 and nanoch32v203-usbfs (stale container: "expected data block for tid 5, got type 3 tid 4"). The MTP example cancels by stalling both bulk endpoints with a response still loaded on bulk IN; the host's CLEAR_FEATURE(ENDPOINT_HALT) is a host-to-device request, so the hold engages, and dcd_edpt_clear_stall() leaves that cancelled transfer marked valid at NAK. The release re-armed every NAK+valid IN endpoint and so resent the stale packet. Track the endpoints the hold itself NAKed and re-arm only those, still NAK with a packet loaded. Open, stall and clear-stall drop their endpoint from that set with the IRQ masked, so a response they set is never undone by the release. --- src/portable/wch/dcd_ch32_usbfs.c | 55 +++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/src/portable/wch/dcd_ch32_usbfs.c b/src/portable/wch/dcd_ch32_usbfs.c index 915bb8484b..bafb494792 100644 --- a/src/portable/wch/dcd_ch32_usbfs.c +++ b/src/portable/wch/dcd_ch32_usbfs.c @@ -137,7 +137,8 @@ static struct { bool ep0_tog; uint8_t ep0_status_dir; #ifdef CH32_USBFS_H2D_IN_HOLD - bool in_hold; // non-EP0 IN endpoints held at NAK for a host-to-device control transfer + bool in_hold; // non-EP0 IN endpoints held at NAK for a host-to-device control transfer + uint8_t in_parked; // bit n: IN endpoint n was NAKed by the hold and is re-armed on release #endif bool isochronous[EP_MAX][2]; // per [ep][dir]: an ep number may be iso in one direction struct usb_xfer xfer[EP_MAX][2]; @@ -242,30 +243,62 @@ static inline bool ep_shares_ep0_dma(uint8_t ep) { // the host times out the data or status stage (seen on CH32V203 with bulk IN streaming alongside // CDC SET_LINE_CODING / SET_CONTROL_LINE_STATE). Hold the non-iso IN endpoints at NAK from such a // SETUP until its status stage completes. OUT traffic and device-to-host transfers are unaffected. +// The release re-arms only the endpoints the hold itself parked: a transfer can stay marked valid +// after a stall + clear-stall cancelled it, and re-arming that would resend a stale packet. static inline bool ep_in_held(void) { return data.in_hold; } +static inline void ep_in_park(uint8_t ep) { + ep_tx_set_response(ep, USBFS_EP_T_RES_NAK); + data.in_parked |= (uint8_t) TU_BIT(ep); +} + static void ep_in_hold(bool hold) { data.in_hold = hold; for (uint8_t ep = 1; ep < EP_MAX; ep++) { if (data.isochronous[ep][TUSB_DIR_IN]) { continue; } const uint8_t res = ep_tx_get_response(ep); - if (hold && res == USBFS_EP_T_RES_ACK) { - ep_tx_set_response(ep, USBFS_EP_T_RES_NAK); - } else if (!hold && res == USBFS_EP_T_RES_NAK && data.xfer[ep][TUSB_DIR_IN].valid) { - ep_tx_set_response(ep, USBFS_EP_T_RES_ACK); // a packet is loaded: re-arm it (STALL stays) + if (hold) { + if (res == USBFS_EP_T_RES_ACK) { ep_in_park(ep); } + } else if ((data.in_parked & TU_BIT(ep)) && res == USBFS_EP_T_RES_NAK && data.xfer[ep][TUSB_DIR_IN].valid) { + ep_tx_set_response(ep, USBFS_EP_T_RES_ACK); } } + if (!hold) { data.in_parked = 0; } +} + +// Open, stall and clear-stall take an IN endpoint's response over from the hold. The IRQ stays +// masked until their write is done, so neither a SETUP nor a status stage can interleave with it. +static inline void ep_in_claim_begin(uint8_t rhport, uint8_t ep) { + dcd_int_disable(rhport); + data.in_parked &= (uint8_t) ~TU_BIT(ep); +} + +static inline void ep_in_claim_end(uint8_t rhport) { + dcd_int_enable(rhport); } #else static inline bool ep_in_held(void) { return false; } +static inline void ep_in_park(uint8_t ep) { + (void) ep; +} + static inline void ep_in_hold(bool hold) { (void) hold; } + +static inline void ep_in_claim_begin(uint8_t rhport, uint8_t ep) { + (void) rhport; + (void) ep; +} + +static inline void ep_in_claim_end(uint8_t rhport) { + (void) rhport; +} #endif static void update_in(uint8_t rhport, uint8_t ep, bool force) { @@ -289,7 +322,11 @@ static void update_in(uint8_t rhport, uint8_t ep, bool force) { } else if (data.isochronous[ep][TUSB_DIR_IN]) { ep_tx_set_response(ep, USBFS_EP_T_RES_NYET); } else { - ep_tx_set_response(ep, ep_in_held() ? USBFS_EP_T_RES_NAK : USBFS_EP_T_RES_ACK); + if (ep_in_held()) { + ep_in_park(ep); // armed during a host-to-device control transfer: sent after its status stage + } else { + ep_tx_set_response(ep, USBFS_EP_T_RES_ACK); + } } } else { xfer->valid = false; @@ -529,7 +566,9 @@ bool dcd_edpt_open(uint8_t rhport, const tusb_desc_endpoint_t *desc_ep) { if (dir == TUSB_DIR_OUT) { ep_rx_ctrl_set(ep, EP_R_AUTO_TOG | USBFS_EP_R_RES_NAK); } else { + ep_in_claim_begin(rhport, ep); ep_tx_ctrl_set(ep, EP_T_AUTO_TOG | USBFS_EP_T_RES_NAK); + ep_in_claim_end(rhport); } } return true; @@ -632,7 +671,9 @@ void dcd_edpt_stall(uint8_t rhport, uint8_t ep_addr) { if (dir == TUSB_DIR_OUT) { ep_rx_set_response(ep, USBFS_EP_R_RES_STALL); } else { + ep_in_claim_begin(rhport, ep); ep_tx_set_response(ep, USBFS_EP_T_RES_STALL); + ep_in_claim_end(rhport); } } } @@ -659,10 +700,12 @@ void dcd_edpt_clear_stall(uint8_t rhport, uint8_t ep_addr) { #endif ep_rx_ctrl_set(ep, EP_R_AUTO_TOG | res); } else { + ep_in_claim_begin(rhport, ep); #ifdef CH32_USBFS_EP4_MANUAL_TOG ep_tx_ctrl_set(ep, USBFS_EP_T_RES_NAK); #endif ep_tx_ctrl_set(ep, EP_T_AUTO_TOG | USBFS_EP_T_RES_NAK); + ep_in_claim_end(rhport); } } } From 9324f79c804d5a79eb8755342ba6a4552f010567 Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Sat, 17 Oct 2026 16:27:34 +0200 Subject: [PATCH 3/4] wch/usbfs: limit the IN hold to CH32V20x The hold was enabled for CH32V103 on the strength of a matching intermittent HIL failure (cdc_msc_throughput "CDC dd read failed", 1 of 7 recent master runs). With the hold, the V103 still failed it once and passed on the re-run, so that failure is not shown to share the V20x cause. Keep the quirk to CH32V20x, where it is traced on the wire; V103 builds are unchanged from master again. --- src/portable/wch/ch32_usbfs_reg.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/portable/wch/ch32_usbfs_reg.h b/src/portable/wch/ch32_usbfs_reg.h index 45aba7893b..2f96c2703a 100644 --- a/src/portable/wch/ch32_usbfs_reg.h +++ b/src/portable/wch/ch32_usbfs_reg.h @@ -194,10 +194,10 @@ #define CH32_USBFS_EP3_1023_BUFSIZE 1 #endif -// CH32V103/V20x USBFS lose EP0 when an IN completes on another endpoint during a host-to-device control -// transfer (see ep_in_hold() in dcd_ch32_usbfs.c). Traced on CH32V203; CH32V103 fails the same HIL test. -// CH32V305 USBFS was checked unaffected; X035/CH58x/F20x are untested. -#if CFG_TUSB_MCU == OPT_MCU_CH32V103 || CFG_TUSB_MCU == OPT_MCU_CH32V20X +// CH32V20x USBFS loses EP0 when an IN completes on another endpoint during a host-to-device control +// transfer (see ep_in_hold() in dcd_ch32_usbfs.c), traced on CH32V203. CH32V305 USBFS was checked +// unaffected; V103/X035/CH58x/F20x are untested. +#if CFG_TUSB_MCU == OPT_MCU_CH32V20X #define CH32_USBFS_H2D_IN_HOLD 1 #endif From f9c65d41605ba9c3a1ef3bc032f6bab48adeda72 Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 22 Oct 2026 14:20:18 +0700 Subject: [PATCH 4/4] wch/usbfs: release the IN hold only once EP0 IN is set to STALL usbd stalls EP0 OUT before EP0 IN; releasing the hold on the OUT stall left a window where a parked IN could complete before EP0 IN answered STALL. --- src/portable/wch/dcd_ch32_usbfs.c | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/portable/wch/dcd_ch32_usbfs.c b/src/portable/wch/dcd_ch32_usbfs.c index bafb494792..ece0f4fe62 100644 --- a/src/portable/wch/dcd_ch32_usbfs.c +++ b/src/portable/wch/dcd_ch32_usbfs.c @@ -552,7 +552,6 @@ void dcd_edpt0_status_complete(uint8_t rhport, const tusb_control_request_t *req } bool dcd_edpt_open(uint8_t rhport, const tusb_desc_endpoint_t *desc_ep) { - (void)rhport; uint8_t ep = tu_edpt_number(desc_ep->bEndpointAddress); uint8_t dir = tu_edpt_dir(desc_ep->bEndpointAddress); TU_ASSERT(ep < EP_MAX); @@ -650,19 +649,20 @@ bool dcd_edpt_xfer(uint8_t rhport, uint8_t ep_addr, uint8_t *buffer, uint16_t to } void dcd_edpt_stall(uint8_t rhport, uint8_t ep_addr) { - (void)rhport; uint8_t ep = tu_edpt_number(ep_addr); uint8_t dir = tu_edpt_dir(ep_addr); if (ep == 0) { - // A stalled control transfer has no status stage to release the IN hold: release it before the - // host can see the STALL and start the next transfer, IRQ masked so no SETUP lands mid-release. - if (ep_in_held()) { + if (dir == TUSB_DIR_OUT) { + ep_rx_ctrl_set(0, USBFS_EP_R_RES_STALL); + } else if (ep_in_held()) { + // A stalled control transfer has no status stage to release the IN hold. usbd stalls EP0 OUT + // first: release only once EP0 IN is set to STALL, or a parked IN completing in between would be + // taken as the status stage. IRQ masked so no SETUP lands mid-release. dcd_int_disable(rhport); + EP_TX_LEN(0) = 0; + ep_tx_ctrl_set(0, USBFS_EP_T_RES_STALL); ep_in_hold(false); dcd_int_enable(rhport); - } - if (dir == TUSB_DIR_OUT) { - ep_rx_ctrl_set(0, USBFS_EP_R_RES_STALL); } else { EP_TX_LEN(0) = 0; ep_tx_ctrl_set(0, USBFS_EP_T_RES_STALL); @@ -679,7 +679,6 @@ void dcd_edpt_stall(uint8_t rhport, uint8_t ep_addr) { } void dcd_edpt_clear_stall(uint8_t rhport, uint8_t ep_addr) { - (void)rhport; uint8_t ep = tu_edpt_number(ep_addr); uint8_t dir = tu_edpt_dir(ep_addr); if (ep == 0) {