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:
- prerequisiteSerial communication basicsHow bytes travel one bit at a time over a wire: baud rate, start and stop bits, and the difference between a UART and USB.
- techniqueChips, boards, and configsThe ESP32 family as the ESP32 Inspector catalogs it: which chips and boards exist on the workbench, and the five configs that group them by processor core and USB path.
- prerequisitePolling and interruptsTwo ways a program notices that something happened: asking over and over, or being interrupted when it does.
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: , 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 microseconds, so the byte takes about 86.8 microseconds, and the line carries at most 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.UARTon 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
- Serial communication basics, for the UART frame bit by bit.
- RISC-V assembly on the ESP32-C6 and P4 and Xtensa assembly on the ESP32 and ESP32-S3, which walk the full echo listings.
- Debugging resets and crashes, for the C6 reset loop the native USB path caused.
- The UART and USB-Serial/JTAG chapters of the ESP32-C6 Technical Reference Manual.
- Watching the echo on real hardware with the Monitor tab of the ESP32 Inspector, in desktop Chromium.
Back to ESP32 development: assembly, C, MicroPython, and CircuitPython