From 0e84235f9aa6eddfec184f76d83c3063aa130f16 Mon Sep 17 00:00:00 2001 From: deadprogram Date: Tue, 14 Apr 2026 08:45:00 +0200 Subject: [PATCH] machine/esp32s3,esp32c3: add txStalled flag to skip USB serial spin when no host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no USB host is reading, flushAndWait() spins 50K iterations per FIFO-full event. With putchar calling WriteByte per byte, the cumulative delay starves I2C and other peripherals, freezing displays. Add a txStalled flag: the first FIFO-full triggers one flushAndWait attempt. If it fails (no host), txStalled is set and all subsequent writes return immediately with no spin — just a register read and a bool check. When a host reconnects, SERIAL_IN_EP_DATA_FREE goes back to 1, bypassing the stall path and clearing the flag automatically. --- src/machine/machine_esp32c3_usb.go | 82 ++++++++++++++++++++++-------- src/machine/machine_esp32xx_usb.go | 82 ++++++++++++++++++++++-------- src/runtime/runtime_esp32c3.go | 1 + src/runtime/runtime_esp32sx.go | 1 + 4 files changed, 124 insertions(+), 42 deletions(-) diff --git a/src/machine/machine_esp32c3_usb.go b/src/machine/machine_esp32c3_usb.go index 756eb8bf7..5d58c35ee 100644 --- a/src/machine/machine_esp32c3_usb.go +++ b/src/machine/machine_esp32c3_usb.go @@ -19,15 +19,11 @@ import ( const cpuInterruptFromUSB = 10 -// flushTimeout is the maximum number of busy-wait iterations in flush(). -// Must be long enough for 2-3 USB frames (~3ms at 160MHz) so data gets -// through when a host is connected, but short enough that println doesn't -// freeze the application when no host is reading. -const flushTimeout = 50000 - type USB_DEVICE struct { - Bus *esp.USB_DEVICE_Type - Buffer *RingBuffer + Bus *esp.USB_DEVICE_Type + Buffer *RingBuffer + txPending bool // unflushed data in the EP1 TX FIFO + txStalled bool // set when flushAndWait fails (no host reading); cleared when FIFO becomes writable } var ( @@ -147,19 +143,36 @@ func (usbdev *USB_DEVICE) handleInterrupt() { func (usbdev *USB_DEVICE) WriteByte(c byte) error { usbdev.ensureConfigured() if usbdev.Bus.GetEP1_CONF_SERIAL_IN_EP_DATA_FREE() == 0 { - // FIFO not writable — try a short flush to nudge the hardware - // (e.g. after reset the FIFO may need WR_DONE to transition). - usbdev.flush() - if usbdev.Bus.GetEP1_CONF_SERIAL_IN_EP_DATA_FREE() == 0 { + // FIFO locked by a pending USB transfer. + if usbdev.txStalled { + // Previously failed — skip the expensive spin and drop + // the byte. When a host reconnects SERIAL_IN_EP_DATA_FREE + // goes back to 1, clearing the stall on the next call. + return errUSBCouldNotWriteAllData + } + // First time the FIFO is full: wait briefly for the host to + // read the previous packet. + if !usbdev.flushAndWait() { + usbdev.txStalled = true return errUSBCouldNotWriteAllData } } + usbdev.txStalled = false // Use EP1.Set() (direct store) instead of SetEP1_RDWR_BYTE which // does a read-modify-write — the read side-effect pops a byte from // the RX FIFO. usbdev.Bus.EP1.Set(uint32(c)) - usbdev.flush() + + // Only signal WR_DONE on newline to batch bytes into a single USB + // packet. The FIFO-full path above also flushes when the 64-byte + // FIFO fills up. + if c == '\n' { + usbdev.flush() + usbdev.txPending = false + } else { + usbdev.txPending = true + } return nil } @@ -172,23 +185,32 @@ func (usbdev *USB_DEVICE) Write(data []byte) (n int, err error) { for i, c := range data { if usbdev.Bus.GetEP1_CONF_SERIAL_IN_EP_DATA_FREE() == 0 { - if i > 0 { - usbdev.flush() + if usbdev.txStalled { + return i, errUSBCouldNotWriteAllData } - if usbdev.Bus.GetEP1_CONF_SERIAL_IN_EP_DATA_FREE() == 0 { + if !usbdev.flushAndWait() { + usbdev.txStalled = true return i, errUSBCouldNotWriteAllData } } + usbdev.txStalled = false usbdev.Bus.EP1.Set(uint32(c)) } usbdev.flush() + usbdev.txPending = false return len(data), nil } // Buffered returns the number of bytes waiting in the receive ring buffer. func (usbdev *USB_DEVICE) Buffered() int { usbdev.ensureConfigured() + // Flush any pending TX data so callers like echo loops don't + // need to explicitly flush after WriteByte. + if usbdev.txPending { + usbdev.flush() + usbdev.txPending = false + } return int(usbdev.Buffer.Used()) } @@ -209,16 +231,34 @@ func (usbdev *USB_DEVICE) RTS() bool { return false } -// flush signals WR_DONE and briefly waits for the hardware to accept more -// data. The timeout is intentionally short so that serial output never -// stalls the application when no USB host is reading. +// flush signals WR_DONE to tell the hardware to send the data that has +// been written to the EP1 FIFO. Returns immediately without waiting. func (usbdev *USB_DEVICE) flush() { usbdev.Bus.SetEP1_CONF_WR_DONE(1) - for i := 0; i < flushTimeout; i++ { +} + +// FlushSerial flushes any pending USB serial TX data. Called from the +// runtime (e.g. before sleeping) to ensure data from print() without +// a trailing newline gets sent promptly. +func FlushSerial() { + if _USBCDC.txPending { + _USBCDC.flush() + _USBCDC.txPending = false + } +} + +// flushAndWait signals WR_DONE and waits for the EP1 FIFO to become +// writable again. The timeout covers a few USB frames so that data gets +// through when a host is connected. Returns false if the FIFO is still +// locked after the timeout (no host reading). +func (usbdev *USB_DEVICE) flushAndWait() bool { + usbdev.Bus.SetEP1_CONF_WR_DONE(1) + for i := 0; i < 50000; i++ { if usbdev.Bus.GetEP1_CONF_SERIAL_IN_EP_DATA_FREE() != 0 { - return + return true } } + return false } // The ESP32-C3 USB Serial/JTAG controller is fixed-function hardware. diff --git a/src/machine/machine_esp32xx_usb.go b/src/machine/machine_esp32xx_usb.go index f32869e72..e9d7275d9 100644 --- a/src/machine/machine_esp32xx_usb.go +++ b/src/machine/machine_esp32xx_usb.go @@ -19,15 +19,11 @@ import ( const cpuInterruptFromUSB = 8 -// flushTimeout is the maximum number of busy-wait iterations in flush(). -// Must be long enough for 2-3 USB frames (~3ms at 240MHz) so data gets -// through when a host is connected, but short enough that println doesn't -// freeze the application when no host is reading. -const flushTimeout = 50000 - type USB_DEVICE struct { - Bus *esp.USB_DEVICE_Type - Buffer *RingBuffer + Bus *esp.USB_DEVICE_Type + Buffer *RingBuffer + txPending bool // unflushed data in the EP1 TX FIFO + txStalled bool // set when flushAndWait fails (no host reading); cleared when FIFO becomes writable } var ( @@ -125,19 +121,36 @@ func (usbdev *USB_DEVICE) handleInterrupt() { func (usbdev *USB_DEVICE) WriteByte(c byte) error { usbdev.ensureConfigured() if usbdev.Bus.GetEP1_CONF_SERIAL_IN_EP_DATA_FREE() == 0 { - // FIFO not writable — try a short flush to nudge the hardware - // (e.g. after reset the FIFO may need WR_DONE to transition). - usbdev.flush() - if usbdev.Bus.GetEP1_CONF_SERIAL_IN_EP_DATA_FREE() == 0 { + // FIFO locked by a pending USB transfer. + if usbdev.txStalled { + // Previously failed — skip the expensive spin and drop + // the byte. When a host reconnects SERIAL_IN_EP_DATA_FREE + // goes back to 1, clearing the stall on the next call. + return errUSBCouldNotWriteAllData + } + // First time the FIFO is full: wait briefly for the host to + // read the previous packet. + if !usbdev.flushAndWait() { + usbdev.txStalled = true return errUSBCouldNotWriteAllData } } + usbdev.txStalled = false // Use EP1.Set() (direct store) instead of SetEP1_RDWR_BYTE which // does a read-modify-write — the read side-effect pops a byte from // the RX FIFO. usbdev.Bus.EP1.Set(uint32(c)) - usbdev.flush() + + // Only signal WR_DONE on newline to batch bytes into a single USB + // packet. The FIFO-full path above also flushes when the 64-byte + // FIFO fills up. + if c == '\n' { + usbdev.flush() + usbdev.txPending = false + } else { + usbdev.txPending = true + } return nil } @@ -150,17 +163,20 @@ func (usbdev *USB_DEVICE) Write(data []byte) (n int, err error) { for i, c := range data { if usbdev.Bus.GetEP1_CONF_SERIAL_IN_EP_DATA_FREE() == 0 { - if i > 0 { - usbdev.flush() + if usbdev.txStalled { + return i, errUSBCouldNotWriteAllData } - if usbdev.Bus.GetEP1_CONF_SERIAL_IN_EP_DATA_FREE() == 0 { + if !usbdev.flushAndWait() { + usbdev.txStalled = true return i, errUSBCouldNotWriteAllData } } + usbdev.txStalled = false usbdev.Bus.EP1.Set(uint32(c)) } usbdev.flush() + usbdev.txPending = false return len(data), nil } @@ -170,6 +186,12 @@ func (usbdev *USB_DEVICE) Write(data []byte) (n int, err error) { // level-triggered interrupt storm). func (usbdev *USB_DEVICE) Buffered() int { usbdev.ensureConfigured() + // Flush any pending TX data so callers like echo loops don't + // need to explicitly flush after WriteByte. + if usbdev.txPending { + usbdev.flush() + usbdev.txPending = false + } // Drain the hardware FIFO into the ring buffer. for usbdev.Bus.GetEP1_CONF_SERIAL_OUT_EP_DATA_AVAIL() != 0 { b := byte(usbdev.Bus.EP1.Get()) @@ -198,16 +220,34 @@ func (usbdev *USB_DEVICE) RTS() bool { return false } -// flush signals WR_DONE and briefly waits for the hardware to accept more -// data. The timeout is intentionally short so that serial output never -// stalls the application when no USB host is reading. +// flush signals WR_DONE to tell the hardware to send the data that has +// been written to the EP1 FIFO. Returns immediately without waiting. func (usbdev *USB_DEVICE) flush() { usbdev.Bus.SetEP1_CONF_WR_DONE(1) - for i := 0; i < flushTimeout; i++ { +} + +// FlushSerial flushes any pending USB serial TX data. Called from the +// runtime (e.g. before sleeping) to ensure data from print() without +// a trailing newline gets sent promptly. +func FlushSerial() { + if _USBCDC.txPending { + _USBCDC.flush() + _USBCDC.txPending = false + } +} + +// flushAndWait signals WR_DONE and waits for the EP1 FIFO to become +// writable again. The timeout covers a few USB frames so that data gets +// through when a host is connected. Returns false if the FIFO is still +// locked after the timeout (no host reading). +func (usbdev *USB_DEVICE) flushAndWait() bool { + usbdev.Bus.SetEP1_CONF_WR_DONE(1) + for i := 0; i < 50000; i++ { if usbdev.Bus.GetEP1_CONF_SERIAL_IN_EP_DATA_FREE() != 0 { - return + return true } } + return false } // The ESP32-S3 USB Serial/JTAG controller is fixed-function hardware. diff --git a/src/runtime/runtime_esp32c3.go b/src/runtime/runtime_esp32c3.go index abae59533..761a14d1c 100644 --- a/src/runtime/runtime_esp32c3.go +++ b/src/runtime/runtime_esp32c3.go @@ -151,6 +151,7 @@ func initTimerInterrupt() { // sleepTicks spins until the given number of ticks have elapsed, using the // TIMG0 alarm interrupt to avoid busy-waiting for the entire duration. func sleepTicks(d timeUnit) { + machine.FlushSerial() target := ticks() + d for ticks() < target { // Set the alarm to fire at the target tick count (or as close diff --git a/src/runtime/runtime_esp32sx.go b/src/runtime/runtime_esp32sx.go index 2a9278355..4f8c75dcc 100644 --- a/src/runtime/runtime_esp32sx.go +++ b/src/runtime/runtime_esp32sx.go @@ -110,6 +110,7 @@ func initTimerInterrupt() { // sleepTicks spins until the given number of ticks have elapsed, using the // TIMG0 alarm interrupt to avoid busy-waiting for the entire duration. func sleepTicks(d timeUnit) { + machine.FlushSerial() target := ticks() + d for ticks() < target { // Set the alarm to fire at the target tick count.