machine/esp32s3,esp32c3: add txStalled flag to skip USB serial spin when no host

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.
This commit is contained in:
deadprogram
2026-04-14 08:45:00 +02:00
committed by Ron Evans
parent 894839484c
commit 0e84235f9a
4 changed files with 124 additions and 42 deletions
+61 -21
View File
@@ -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.
+61 -21
View File
@@ -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.
+1
View File
@@ -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
+1
View File
@@ -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.