Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions pkg/espflasher/chip.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ type chipDef struct {
// CHANGE_BAUD command (0x0F). ESP32+ ROMs support this; ESP8266 does not.
ROMHasChangeBaud bool

// MaxUARTFlashBaud caps the flash baud rate for UART bridge connections.
// The ESP32 ROM disables interrupts during flash page writes (because
// the SPI bus is shared with the cache), causing the 128-byte UART RX
// FIFO on common USB-UART bridges (CH340, CP2102) to overflow at high
// baud rates. Set to 0 for no cap (native USB chips have flow control).
MaxUARTFlashBaud int

// SPIMISODLenOffs is the register offset for the MISO data bit length
// register (relative to SPIRegBase). On ESP32-S2 and newer, MISO/MOSI
// lengths are in dedicated registers. On ESP8266 and ESP32, these are 0
Expand Down
76 changes: 58 additions & 18 deletions pkg/espflasher/flasher.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ type connection interface {
eraseRegion(offset, size uint32) error
readFlash(offset, size uint32, progress ProgressFunc) ([]byte, error)
flushInput()
terminatePartialFrame()
isStub() bool
setUSB(v bool)
setSupportsEncryptedFlash(v bool)
Expand Down Expand Up @@ -578,6 +579,13 @@ func (f *Flasher) FlashImage(data []byte, offset uint32, progress ProgressFunc)
return fmt.Errorf("attach flash: %w", err)
}

// Cap baud rate for UART-bridge chips prone to FIFO overflow.
if f.chip != nil && f.chip.MaxUARTFlashBaud > 0 && !f.usesUSB &&
f.opts.FlashBaudRate > f.chip.MaxUARTFlashBaud {
f.logf("Limiting flash baud rate to %d (UART bridge on %s)", f.chip.MaxUARTFlashBaud, f.chip.Name)
f.opts.FlashBaudRate = f.chip.MaxUARTFlashBaud
}

// Optionally switch to higher baud rate (not supported by ESP8266 ROM)
canChangeBaud := f.chip == nil || f.chip.ROMHasChangeBaud || f.conn.isStub()
if canChangeBaud && f.opts.FlashBaudRate > 0 && f.opts.FlashBaudRate != f.opts.BaudRate {
Expand Down Expand Up @@ -641,6 +649,13 @@ func (f *Flasher) FlashImages(images []ImagePart, progress ProgressFunc) error {
f.opts.FlashMode = "dout"
}

// Cap baud rate for UART-bridge chips prone to FIFO overflow.
if f.chip != nil && f.chip.MaxUARTFlashBaud > 0 && !f.usesUSB &&
f.opts.FlashBaudRate > f.chip.MaxUARTFlashBaud {
f.logf("Limiting flash baud rate to %d (UART bridge on %s)", f.chip.MaxUARTFlashBaud, f.chip.Name)
f.opts.FlashBaudRate = f.chip.MaxUARTFlashBaud
}

// Optionally switch to higher baud rate (not supported by ESP8266 ROM)
canChangeBaud := f.chip == nil || f.chip.ROMHasChangeBaud || f.conn.isStub()
if canChangeBaud && f.opts.FlashBaudRate > 0 && f.opts.FlashBaudRate != f.opts.BaudRate {
Expand Down Expand Up @@ -935,13 +950,17 @@ func (f *Flasher) EraseRegion(offset, size uint32, progress ProgressFunc) error
const eraseProgressInterval = 500 * time.Millisecond

// flashBlockRetries is the number of attempts for each flash data block write.
// At high baud rates (460800+), USB-UART bridges occasionally lose bytes during
// transmission, causing the stub to receive a truncated or corrupted SLIP frame.
// The stub detects this (bad data length or bad checksum) and responds with a
// clean error, leaving it ready for a resend. We retry only for these
// serial-integrity errors; device-side failures (SPI errors, inflate errors)
// are not retried.
const flashBlockRetries = 3
// USB-UART bridges occasionally lose bytes during transmission, causing the stub
// to receive a truncated or corrupted SLIP frame. After a timeout, the first
// retry's leading 0xC0 terminates the stub's partial frame, often producing a
// stale "bad data length" error that consumes one retry for cleanup. With 5
// retries we get at least 3 clean attempts after any such cleanup cycle.
const flashBlockRetries = 5

// flashBlockRetryDelay is the delay between flash block retries, allowing
// in-flight stale responses from the stub to arrive and be flushed before
// the next attempt.
const flashBlockRetryDelay = 50 * time.Millisecond

// tickErase runs work (a blocking erase call) while emitting synthetic ETA
// progress updates against est every interval, until work returns. progress
Expand Down Expand Up @@ -1301,13 +1320,23 @@ func (f *Flasher) logf(format string, args ...interface{}) {
//
// 2. TimeoutError: the stub never responded, likely because bytes were lost
// during UART transmission, leaving the stub waiting for the rest of an
// incomplete SLIP frame. On resend, the leading 0xC0 terminates the stub's
// partial frame; the stub may then emit a stale error response for the
// truncated frame before processing our retry, which is handled by the
// next retry iteration.
// incomplete SLIP frame.
//
// After a timeout, the stub may be holding a partial frame. To clean up
// without corrupting the decompressor state (which is critical for compressed
// flash writes), we:
// 1. Send a bare SLIP end byte (0xC0) to terminate the stub's partial frame.
// 2. Wait briefly for the stub to emit a stale error response for the
// truncated data.
// 3. Flush the serial RX buffer to discard that stale response.
// 4. Retry with a clean serial state.
//
// This prevents the retry data from being concatenated with the partial-frame
// termination in a single write, which would cause the stub to process both
// the cleanup and the retry as separate commands — advancing the decompressor
// state on the "invisible" second command while we read the stale error from
// the first.
//
// Between retries the serial RX buffer and SLIP reader are flushed so stale
// responses from partial-frame cleanup don't corrupt subsequent reads.
// Device-side failures (SPI errors, inflate errors) are NOT retried.
func (f *Flasher) retryFlashBlock(seq, numBlocks uint32, writeFn func() error) error {
var err error
Expand All @@ -1322,17 +1351,28 @@ func (f *Flasher) retryFlashBlock(seq, numBlocks uint32, writeFn func() error) e
if attempt < flashBlockRetries-1 {
f.logf("Warning: block %d/%d write failed (attempt %d/%d): %v — retrying",
seq, numBlocks, attempt+1, flashBlockRetries, err)
// Flush stale data so the retry starts with a clean serial state.
// After a timeout the stub may still be holding a partial frame;
// our next send's leading 0xC0 will terminate it, producing a
// stale error response that the flush on the FOLLOWING iteration
// (if needed) will clear.

// If this was a timeout, the stub is likely holding a partial SLIP
// frame. Send a bare 0xC0 to terminate it cleanly, then wait for
// the stale error response before flushing and retrying.
if isTimeoutError(err) {
f.conn.terminatePartialFrame()
}

// Wait for any stale responses to arrive, then flush them.
time.Sleep(flashBlockRetryDelay)
f.conn.flushInput()
}
}
return err
}

// isTimeoutError returns true if err is a TimeoutError.
func isTimeoutError(err error) bool {
var te *TimeoutError
return errors.As(err, &te)
}

// isRetryableFlashError returns true if the error is a transient serial-link
// issue (data corruption or loss) that can be recovered by resending.
func isRetryableFlashError(err error) bool {
Expand Down
49 changes: 46 additions & 3 deletions pkg/espflasher/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package espflasher
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"syscall"
"time"

"go.bug.st/serial"
Expand Down Expand Up @@ -172,12 +174,12 @@ func (c *conn) sendCommand(opcode byte, data []byte, chk uint32) error {
if end > len(frame) {
end = len(frame)
}
if _, err := c.port.Write(frame[off:end]); err != nil {
if err := writeRetryEINTR(c.port, frame[off:end]); err != nil {
return err
}
}
} else {
if _, err := c.port.Write(frame); err != nil {
if err := writeRetryEINTR(c.port, frame); err != nil {
return err
}
}
Expand All @@ -194,7 +196,38 @@ func (c *conn) sendCommand(opcode byte, data []byte, chk uint32) error {
// ensures each frame is committed to the USB-UART bridge before we
// proceed, adding a small but deterministic delay that gives the stub
// more time between consecutive commands.
return c.port.Drain()
return drainRetryEINTR(c.port)
}

// writeRetryEINTR calls port.Write, retrying transparently if interrupted by a
// signal (EINTR). On Linux, signals such as SIGWINCH can interrupt write(2) on
// serial file descriptors before any bytes are transferred.
func writeRetryEINTR(port serial.Port, data []byte) error {
for {
_, err := port.Write(data)
if err == nil {
return nil
}
if errors.Is(err, syscall.EINTR) {
continue
}
return err
}
}

// drainRetryEINTR calls port.Drain, retrying transparently if interrupted by a
// signal (EINTR). On Linux, tcdrain(3) can be interrupted by any signal.
func drainRetryEINTR(port serial.Port) error {
for {
err := port.Drain()
if err == nil {
return nil
}
if errors.Is(err, syscall.EINTR) {
continue
}
return err
}
}

// commandResponse represents a parsed response from the ESP device.
Expand Down Expand Up @@ -712,6 +745,16 @@ func (c *conn) flushInput() {
c.reader.reset()
}

// terminatePartialFrame sends a bare SLIP end byte (0xC0) to terminate any
// partial frame the stub may be holding. This is used during flash write retry
// cleanup: after a timeout, the stub is likely waiting for the rest of an
// incomplete SLIP frame. Sending 0xC0 alone (without any command data) makes
// the stub process the truncated frame and emit an error response, without
// inadvertently feeding it our retry data as a second command in the same write.
func (c *conn) terminatePartialFrame() {
c.port.Write([]byte{slipEnd}) //nolint:errcheck
}

// waitForStubFlashWrite sleeps to let the stub's flash write post-process
// complete before the caller sends the next command. See stubFlashPageDelay
// for the full rationale. When the ROM bootloader is active (no stub), the
Expand Down
2 changes: 2 additions & 0 deletions pkg/espflasher/protocol_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,8 @@ func (m *mockConnection) flushInput() {
}
}

func (m *mockConnection) terminatePartialFrame() {}

func (m *mockConnection) readFlash(offset, size uint32, progress ProgressFunc) ([]byte, error) {
if m.readFlashFunc != nil {
return m.readFlashFunc(offset, size, progress)
Expand Down
1 change: 1 addition & 0 deletions pkg/espflasher/target_esp32.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ var defESP32 = &chipDef{

ROMHasCompressedFlash: true,
ROMHasChangeBaud: true,
MaxUARTFlashBaud: 230400,

FlashFrequency: map[string]byte{
"80m": 0xF,
Expand Down
2 changes: 1 addition & 1 deletion pkg/espflasher/version.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package espflasher

// Version is the current version of the espflasher library.
const Version = "0.7.1"
const Version = "0.8.0-dev"
Loading