technique

Serial over UART and USB

The two ways an ESP32 talks to your computer, UART0 through a bridge chip or the chip's own USB-Serial/JTAG, and why the echo program differs between them.

Before this

This page assumes you are comfortable with:

Why you need this

The serial console is how you see what your program is doing, how a REPL takes your typing, and how most flashing tools reach the chip. An ESP32 board reaches your computer over one of two very different paths, and the path decides how the board behaves when you reset it, whether the baud rate matters, and what the lowest-level code must do to send one byte. This is stage 4 of the pipeline on the hub, "Talk to hardware", and the reason the running example's uppercase echo exists in two shapes.

The idea

Path 1: UART0 through a bridge chip

A UART (universal asynchronous receiver-transmitter) is the hardware that sends bytes as framed bits on one wire, as described on Serial communication basics. The classic ESP32 has no USB hardware at all. Boards built on it (the CYD, CYDE, and WROOM-32 DevKit) add a separate USB-to-serial bridge chip, a CH340 or CP2102, which is the actual USB device your computer sees. The bridge turns USB packets into UART bits on the ESP32's UART0 pins and back.

The Inspector configs lx6-uart0 (classic ESP32) and lx7-uart0 (the YD-ESP32-S3's CH343 port) use this path.

Path 2: the chip's own USB-Serial/JTAG

The ESP32-S3, C6, and P4 have a built-in USB peripheral called USB-Serial/JTAG. The chip itself is the USB device; there is no bridge. It shows up as USB vendor 0x303A, product 0x1001, "USB Serial Device" on Windows, per the author's board notes. The configs lx7-usbjtag, rv32-usbjtag, and rv32p4-usbjtag use it.

The differences that matter

UART0 via bridge USB-Serial/JTAG
Who is the USB device the bridge chip the ESP32 itself
Baud rate matters: both ends must match; the ROM sets 115200, 8 data bits, no parity, 1 stop bit (8N1) ignored: data moves in USB packets
Pressing reset the COM port stays; only the ESP32 restarts the COM port disappears and comes back (the USB connection re-enumerates)
Sending a byte write it to the TX FIFO; the UART sends it queue it, then flush, or the host never sees it
Nobody listening bytes go out the wire and are lost the send buffer fills and a polling program waits forever

A FIFO (first in, first out) is a small hardware queue of bytes. Re-enumeration means the USB device disconnects and reconnects as far as the computer is concerned: the author's notes record that a physical reset button press re-enumerates the native USB port, because the USB hardware itself resets.

Worked example

The ESP32 Inspector's echo payloads are the same program for every config: wait for a byte, uppercase it if it is a to z, send it back. Comparing two of them shows the two paths exactly. All register facts below are from the comments in those payloads, which cite the ESP32 Technical Reference Manual and ESP-IDF 5.4.1's register headers.

The receive side

This loop is excerpted from the Inspector's lx6-uart0 echo.S, Xtensa LX6 assembly for the classic ESP32, which has run on real CYD hardware. It reads the UART status register and spins until the receive count is not zero.

echo_loop:
    movi    a2, 0x3FF4001C          # UART_STATUS_REG
    l32i    a3, a2, 0               # read status
    extui   a3, a3, 0, 8            # extract bits 7:0 = RXFIFO_CNT
    beqz    a3, echo_loop           # spin until at least one byte arrives
    movi    a2, 0x3FF40000          # UART_FIFO_REG
    l32i    a4, a2, 0               # read byte from RX FIFO into a4

extui ("extract unsigned immediate") pulls out an 8-bit field starting at bit 0. The UART reports a count of waiting bytes.

The send side

This part is excerpted from the Inspector's rv32-usbjtag echo.S, RISC-V assembly for the ESP32-C6, which has run on a real C6 board. It waits for room, queues the byte, then writes 1 to WR_DONE to flush it to the host.

tx_wait:
    li      t0, 0x6000F004           # USB_SERIAL_JTAG_EP1_CONF_REG
    lw      t1, 0(t0)                # read status flags
    andi    t1, t1, 2                # keep bit 1 = SERIAL_IN_EP_DATA_FREE
    beqz    t1, tx_wait              # spin until the TX buffer has room
    li      t0, 0x6000F000           # USB_SERIAL_JTAG_EP1_REG
    sw      t2, 0(t0)                # queue the byte
    li      t0, 0x6000F004           # USB_SERIAL_JTAG_EP1_CONF_REG
    li      t1, 1                    # WR_DONE
    sw      t1, 0(t0)                # flush - host sees the byte now

The USB peripheral reports flags, single bits that say yes or no, not counts.

Register by register

Job lx6-uart0 (classic ESP32) rv32-usbjtag (ESP32-C6)
Data register 0x3FF40000 UART_FIFO_REG: read pops RX, write pushes TX 0x6000F000 EP1: read pops one RX byte, write queues one TX byte
Status register 0x3FF4001C UART_STATUS_REG 0x6000F004 EP1_CONF
"A byte is waiting" bits 7:0 RXFIFO_CNT is not 0 bit 2 SERIAL_OUT_EP_DATA_AVAIL is 1
"Room to send" bits 23:16 TXFIFO_CNT is below 128 bit 1 SERIAL_IN_EP_DATA_FREE is 1
Make the host see it nothing: the UART shifts it out write 1 to bit 0 WR_DONE
Setup needed none: the ROM already set 115200 8N1 none: the ROM already brought USB up
Instructions in the loop 18 21

The three extra instructions are the flush. The other configs only move the addresses: the S3's UART0 is at 0x60000000 with 10-bit count fields, the S3's USB-Serial/JTAG at 0x60038000, and the P4's at 0x500D2000, with the same bit layout as the C6.

Trace the letter q through the C6 loop (the stepper on RISC-V assembly on the ESP32-C6 and P4 runs this same loop one instruction at a time). The host sends 0x71. Bit 2 of 0x6000F004 goes to 1, so the receive spin exits and lw pops 0x71 into t2. 0x71 is between 0x61 (a) and 0x7A (z), so addi subtracts 0x20: 0x71−0x20=0x51\text{0x71} - \text{0x20} = \text{0x51}, which is Q. Bit 1 is 1 (room to send), the byte is queued, WR_DONE flushes it, and the host prints Q.

On the UART path, that one byte at 115200 baud takes 10 bit times on the wire (start bit, 8 data bits, stop bit). One bit lasts 1/115,200≈8.681 / 115{,}200 \approx 8.68 microseconds, so the byte takes about 86.8 microseconds, and the line carries at most 115,200/10=11,520115{,}200 / 10 = 11{,}520 bytes per second.

In an ESP32 project

The same two paths sit under every language. MicroPython's REPL, CircuitPython's console, and ESP-IDF's logging all print through UART0 on a bridge board and through USB-Serial/JTAG on a native-USB board; the uppercase echo in each language is compared on Choosing a language. Board choice decides the path: the YD-ESP32-S3 even has both, a CH343 bridge on its right USB-C port and the chip's native USB on its left, and the Inspector's lx7-uart0 payload talks through the bridge port.

Three behaviors from the author's notes shape real projects:

  • Opening the port can reset the board. On a C6, host activity on the USB lines can trigger a reset that the boot log reports as rst:0x15 (USB_UART_HPSYS). On bridge boards, the DTR and RTS lines are wired to the reset and boot pins so flashing tools can reset the chip. Either way, a serial monitor opening is not always harmless. The boot-time side of this is on The boot sequence.
  • A USB write blocks if nothing drains it. The author's notes record that writes to the native USB FIFO block when no host is reading, so a "hung" program may just be waiting in its send loop. The same notes give the host side: a program writing to a P4 should use a write timeout.
  • The port vanishes on reset. After a physical reset of a native-USB board the COM port disappears and returns. A terminal that does not reconnect will sit silent over a program that is running fine.

Common mistakes

  • Wrong baud on a UART board. Symptom: random characters instead of text. Set 115200 to match the ROM default, or whatever your program set.
  • Forgetting WR_DONE on USB-Serial/JTAG. Symptom: the program runs, the host sees nothing, or sees bytes only in bursts when some later flush happens.
  • Expecting the COM port to survive a reset. Symptom: the terminal goes quiet after you press reset on an S3, C6, or P4 board, and the port number may change. Reconnect.
  • Two boards with the same bridge chip. Symptom: you flash or monitor the wrong board. Two CH340 boards can swap port names; plug in one at a time.
  • Using busio.UART on the YD-ESP32-S3's GPIO 43 and 44 to reach the bridge port. The author's keyboard project notes that this does not reach the board's bridge chip; details on CircuitPython on the ESP32-S3.
  • Assuming silence means a crash. Symptom: a native-USB program "hangs". It may be blocked in its send loop because no host is reading.

Cost

Neither path costs flash or RAM worth counting: the echo loop is 18 or 21 instructions, and both peripherals are already set up by the ROM. Speed differs: the UART path is capped at 11,520 bytes per second at 115200 baud, while the USB path has no baud limit and moves up to 64 bytes per flush. Both echo programs poll, so the CPU runs at full speed doing nothing useful while it waits, which costs power; Polling and interrupts covers the alternative. The maker's time cost is in the native-USB path's resets and reconnects, which are the source of most "it worked, then went silent" reports.

Going further

Back to ESP32 development: assembly, C, MicroPython, and CircuitPython