Universal Asynchronous Receiver-Transmitter

A Universal Asynchronous Receiver-Transmitter (UART) is the small SoC peripheral that converts bytes into a stream of timed serial bits on a single wire (and the reverse on the other direction), with no shared clock between sender and receiver. It frames each byte with a start bit, optional parity, and one or more stop bits, and uses a baud-rate clock that is typically run at sixteen times the bit rate so the receiver can sample the middle of each cell (Wikipedia, “UART”). The de facto programming model since the early 1990s is the NS16550 family from National Semiconductor, whose ten-register layout (RBR/THR, IER, IIR/FCR, LCR, MCR, LSR, MSR, SCR, DLL/DLM) is so universal that every open-source RISC-V SoC, every FPGA tutorial, and the Linux kernel’s 8250_core.c all assume it (TI TL16C550C datasheet; Wikipedia, “16550 UART”).

Why the UART Survives

The UART is older than the silicon it runs on; the first integrated one shipped in 1971, but the underlying asynchronous framing dates to the teletype era (Wikipedia, “UART”). Every other interface has come and gone: PS/2, parallel, serial COM ports on the back of PCs. Yet the UART remains the universal console for embedded development for one simple reason. It needs only two wires (TX and RX, plus ground), it requires no shared clock, it can be implemented in software on any microcontroller, it works at any speed the receiver agrees to, and the protocol is so simple that an oscilloscope alone can decode it. A Raspberry Pi, an ESP32, a Tang Nano FPGA, a serial console on a server: all of them speak the same five-decade-old protocol, and all of them use the same 16550 register set in software.

Mental Model

flowchart LR
  subgraph TX["Transmit side"]
    THR["THR / TX FIFO"]
    TX_SHIFT["TX shift register"]
    TX_DIV["Baud divisor (DLL/DLM)<br/>÷ to 16× bit clock"]
    THR --> TX_SHIFT
    TX_DIV --> TX_SHIFT
  end
  subgraph LINE["Wire (single signal, idle high)"]
    BIT["1 start | 5..8 data | 0/1 parity | 1/1.5/2 stop"]
  end
  subgraph RX["Receive side"]
    RX_SHIFT["RX shift register<br/>(sample at 8/16 of cell)"]
    RBR["RBR / RX FIFO"]
    RX_DIV["Receiver oversample clock (16×)"]
    RX_SHIFT --> RBR
    RX_DIV --> RX_SHIFT
  end
  TX_SHIFT -->|"TX"| BIT
  BIT -->|"RX"| RX_SHIFT

UART data flow with a 16550-style register interface. What it shows: software writes a byte to the transmit holding register, the UART’s transmit shift register clocks it out one bit at a time at the configured baud rate, the receiver oversamples the incoming line at 16x the bit rate to find the centre of each cell, and the assembled byte ends up in the receive buffer register. The baud divisor is loaded into DLL/DLM and divides a reference clock down to the 16x baud rate. The insight to take: there is no shared clock between the two sides. The 16x oversampling is the trick that lets the receiver lock to a frame without one: after seeing the falling edge of the start bit, it waits 8 sample ticks (half a bit cell) and then samples every 16 ticks thereafter, hitting the middle of each subsequent bit even if the two clocks differ by a few percent.

Mechanical Walk-through

The frame

A UART frame is a tiny self-contained packet on the wire. The line idles high (logical 1). To transmit a character, the sender:

  1. Drives the line low for one bit time. This is the start bit. The receiver detects the falling edge and uses it to align its sample clock.
  2. Drives the line for N bit times, LSB first, where N is the configured data-bit width (5, 6, 7, or 8 in the 16550, up to 9 in some designs).
  3. Optionally drives a parity bit (even, odd, mark, space, or none).
  4. Drives the line high for 1, 1.5, or 2 bit times. These are the stop bits; they guarantee at least one rising edge between back-to-back frames so the next start bit’s falling edge is unambiguous.

Total frame time at 8N1 (8 data, no parity, 1 stop) is 10 bit times for 8 bits of data, giving 80% efficiency on the wire (Wikipedia, “UART”). At 115200 baud, that is 86.8 microseconds per byte and a sustained 11520 bytes per second. Faster baud rates trade signal-integrity headroom for throughput; 1 Mbaud is the upper end of what a typical TL16C550 will do reliably (TL16C550C datasheet, section 2).

Baud rate and oversampling

The transmit shift register clocks one bit per baud period. Internally, the UART runs at 16x the baud rate; this 16x clock is generated from a reference clock divided by the 16-bit value in the divisor latches (DLL low byte, DLM high byte). On the TI TL16C550C, “the divisor latches… allow division of any input reference clock by 1 to (2^16 - 1) and generate an internal 16x clock” (TL16C550C section 1). For a typical 1.8432 MHz crystal, divisor 1 gives 115200 baud; for a 50 MHz FPGA clock, divisor 27 gives ~115740 baud, close enough to 115200 that the receiver locks in.

The receiver also runs a 16x clock. When it detects the start-bit falling edge, it waits 8 ticks (mid-bit) and re-samples; if the line is still low, it accepts the start. Thereafter it samples every 16 ticks, hitting the middle of each subsequent bit cell. The half-bit offset gives the link tolerance to clock mismatch: as long as the sender and receiver clocks are within roughly 2-3% of each other, the cumulative drift over a 10-bit frame stays under half a bit, and the samples land in the right cells. This is the principle the original Gordon Bell UART design exploited “to convert the signal into the digital domain, allowing more reliable timing than previous circuits” (Wikipedia, “UART”).

The NS16550 register set

The 16550, introduced by National Semiconductor in the late 1980s, added a 16-byte FIFO on each direction to the older NS8250/NS16450 design (Wikipedia, “16550 UART”). The original part had a famous bug (“the original 16550 had a bug that prevented this FIFO from being used”); the NS16550A fixed it and is the variant every modern soft-IP clone targets. The TI TL16C550C is a contemporary CMOS clone of the 16550A. Its register selection is governed by three address bits (A0, A1, A2) and one mode bit (DLAB, bit 7 of LCR) that swaps in the divisor latches in place of RBR/THR/IER (TL16C550C Table 7-1):

OffsetDLAB=0 readDLAB=0 writeDLAB=1
0x0RBR (receive buffer)THR (transmit holding)DLL (divisor LSB)
0x1IER (interrupt enable)DLM (divisor MSB)
0x2IIR (interrupt ID)FCR (FIFO control)
0x3LCR (line control)
0x4MCR (modem control)
0x5LSR (line status)
0x6MSR (modem status)
0x7SCR (scratch)

Each register is one byte wide; the eight offsets in the 16550 layout are usually stride-1 on small chips but on modern memory-mapped buses they are often spaced 4 bytes apart (so RBR is at base+0, IER at base+4, etc.) to fit a 32-bit memory-mapped slave’s natural addressing.

Per-register summary:

  • RBR (read) / THR (write) at offset 0 (DLAB=0). A read pulls the next byte from the RX FIFO; a write enqueues a byte into the TX FIFO. The FIFOs are 16 entries each on the 16550A.
  • IER at offset 1 (DLAB=0). Bit 0 enables “received data available” interrupt (ERBI); bit 1 enables “transmit holding register empty” (ETBEI); bit 2 enables “receiver line status” (ELSI, for framing/parity/overrun errors); bit 3 enables “modem status” (EDSSI) (TL16C550C section 7.7.5).
  • IIR (read) / FCR (write) at offset 2. IIR encodes the highest-priority pending interrupt in bits 0-3 (bit 0 = 0 means “an interrupt is pending”). FCR enables/disables the FIFOs and selects the RX trigger level (1, 4, 8, or 14 bytes) at which the “data ready” interrupt fires.
  • LCR at offset 3. The line control register sets the frame format: bits 0-1 select word length (5..8 bits), bit 2 selects 1 vs. 1.5/2 stop bits, bits 3-5 control parity, and bit 7 is the DLAB that swaps DLL/DLM into RBR/THR/IER’s place (TL16C550C section 7.7.7).
  • MCR at offset 4. Modem control: drives the DTR, RTS, OUT1, OUT2 lines. OUT2 is conventionally used to gate the UART’s interrupt line on PC hardware.
  • LSR at offset 5. Line status: bit 0 = data ready (RX FIFO non-empty), bit 1 = overrun (RX FIFO was full when the next byte arrived), bit 2 = parity error, bit 3 = framing error, bit 4 = break detect, bit 5 = THR empty, bit 6 = transmitter empty (TX shift also drained) (TL16C550C section 7.7.8).
  • MSR at offset 6. Modem status, mostly only useful with full hardware flow control.
  • SCR at offset 7. An eight-bit scratch register with no hardware effect: the driver can use it as cheap nonvolatile storage during runtime.
  • DLL/DLM at offsets 0 and 1 (DLAB=1). The 16-bit divisor latch that sets the baud rate.

Interrupts

The IIR encodes a priority scheme so software can determine the cause with a single read (TL16C550C section 7.7.6):

  • Receiver line status (highest priority): cleared by reading LSR.
  • Received data available: cleared by reading RBR until the FIFO drains below the trigger level.
  • Character timeout indication (FIFO mode only): RX FIFO non-empty but below trigger and idle for 4 character times. Cleared by reading RBR.
  • Transmit holding register empty: cleared by writing THR or reading IIR.
  • Modem status: cleared by reading MSR.

The character timeout is the small but crucial feature that makes the RX FIFO usable for line-oriented terminal input: without it, a partial line shorter than the trigger level would never interrupt and the driver would have to poll.

The framing of a single byte at the bit level

Idle  Start  D0    D1    D2    D3    D4    D5    D6    D7    Stop  Idle
 1     0     1     0     1     1     0     1     0     1     1     1
       |<-- 1 bit -->|<-- 1 bit -->|       ...        |<- 1 bit ->|
       ^
       falling edge: receiver aligns

The character 0xAD (binary 10101101, LSB first as 10110101) framed at 8N1 looks like the diagram above. Cell time is 1 / baud seconds. The receiver waits for the falling edge into the start cell, waits 8 oversample ticks for the centre of that cell, confirms the line is still low (rejecting glitches), then samples once every 16 ticks for the next 9 cells (8 data + 1 stop), then returns to idle hunt.

A framing error is what the receiver reports if the bit it samples in the stop cell is low instead of high. A parity error is reported when an odd-or-even parity check on the 8 data bits disagrees with the parity bit. An overrun is when a complete frame finishes but the RX FIFO is full, so the byte is dropped.

Configuration / Code

A minimal NS16550-compatible Rust UART driver for definitely-not-esp32, assuming a 32-bit memory-mapped slave with 4-byte stride (which is the QEMU virt and most soft-IP layout). UART base is 0x1000_0000:

const UART_BASE: usize = 0x1000_0000;
 
#[inline] fn reg(off: usize) -> *mut u8 { (UART_BASE + 4 * off) as *mut u8 }
 
const RBR: usize = 0; const THR: usize = 0;
const IER: usize = 1;
const FCR: usize = 2; const IIR: usize = 2;
const LCR: usize = 3;
const MCR: usize = 4;
const LSR: usize = 5;
const DLL: usize = 0;  // when DLAB=1
const DLM: usize = 1;  // when DLAB=1
 
const LCR_DLAB:     u8 = 0x80;
const LCR_8N1:      u8 = 0x03;          // 8 data, no parity, 1 stop
const FCR_ENABLE:   u8 = 0x01;
const FCR_RX_RESET: u8 = 0x02;
const FCR_TX_RESET: u8 = 0x04;
const FCR_TRIG_8:   u8 = 0x80;          // RX FIFO trigger at 8 bytes
const IER_RX:       u8 = 0x01;
const LSR_THR_EMPTY:u8 = 0x20;
const LSR_DATA_RDY: u8 = 0x01;
 
/// Configure the UART for 115200 8N1, RX interrupts on.
pub fn uart_init(refclk_hz: u32) {
    let divisor = (refclk_hz / (16 * 115200)) as u16;
    unsafe {
        reg(LCR).write_volatile(LCR_DLAB);                 // swap in DLL/DLM
        reg(DLL).write_volatile(divisor as u8);
        reg(DLM).write_volatile((divisor >> 8) as u8);
        reg(LCR).write_volatile(LCR_8N1);                  // back to RBR/THR
        reg(FCR).write_volatile(FCR_ENABLE | FCR_RX_RESET
                               | FCR_TX_RESET | FCR_TRIG_8);
        reg(IER).write_volatile(IER_RX);                   // RX IRQ on
    }
}
 
/// Send one byte, busy-wait on THR empty.
pub fn putc(b: u8) {
    unsafe {
        while reg(LSR).read_volatile() & LSR_THR_EMPTY == 0 {}
        reg(THR).write_volatile(b);
    }
}
 
/// Receive one byte if available.
pub fn try_getc() -> Option<u8> {
    unsafe {
        if reg(LSR).read_volatile() & LSR_DATA_RDY == 0 {
            None
        } else {
            Some(reg(RBR).read_volatile())
        }
    }
}

Line-by-line: refclk_hz / (16 * 115200) is the divisor calculation for 115200 baud with the standard 16x oversampling. Setting DLAB switches the offsets at 0 and 1 from RBR/THR/IER to DLL/DLM, so a write to those addresses sets the divisor; clearing DLAB and writing 0x03 to LCR returns the normal register view and selects 8N1. The FCR write enables both FIFOs, clears them, and sets the RX trigger to 8 bytes (so the “data ready” interrupt fires when at least 8 bytes are queued, or after a character-timeout). IER_RX enables only the receive interrupt; we use polling for TX in this minimal driver. putc busy-waits on LSR bit 5 (THR empty) before writing; on a fast bus this loop is fine because the FIFO usually has room.

Failure Modes and Common Misunderstandings

  • Off-by-one divisor. A divisor of refclk / (16 * baud) rounds to integer; the resulting baud drifts by 1/divisor of the requested value. At 50 MHz reference and 115200 baud, divisor 27 gives 115740 baud (0.47% off); at 9600 baud, divisor 326 gives 9586 baud (0.15% off). The receiver tolerates ~2-3%, so this is usually safe, but at low reference clocks the rounding error can exceed tolerance.
  • DLAB not cleared. A driver that leaves DLAB set after writing the divisor will then “write to THR” but actually overwrite DLL, sending garbage. This is the single most common 16550 driver bug.
  • RX FIFO trigger too high without character timeout enabled. A trigger of 14 bytes will silently swallow a Linux command line until you finally press Enter and accumulate 14 bytes; on a chip that lacks the character-timeout interrupt, the driver must poll periodically.
  • Wrong stride. A 16550 on a parallel bus has stride 1; a memory-mapped 16550 on a 32-bit bus typically has stride 4 (sometimes called “register shift = 2”). Mismatching stride means offset 1 (IER) becomes offset 4 (MCR) and so on; the symptom is interrupts that arrive even when not configured. Check the device tree reg-shift property.
  • Forgetting to clear interrupts in the right order. Reading IIR before RBR can leave the RX interrupt asserted; the canonical sequence is: read IIR to identify cause, dispatch on cause, then perform the clearing read or write specific to that cause.

Alternatives and When to Choose Them

The 16550A is so dominant for compatibility reasons that for a memory-mapped CPU-attached serial port there is essentially no other interface worth using on a hobby SoC. ARM’s PrimeCell PL011 is the only other widely-used contender (used on the Raspberry Pi and most ARM dev boards); it has a similar register layout but is not bit-compatible. The Linux 8250 driver speaks 16550; the PL011 driver is separate. On a RISC-V chip, choose 16550 unless you have a strong reason to mimic an ARM platform.

Outside the 16550 family, modern serial-like interfaces (USB, SPI, I2C) all share the basic “shift register at a configured rate” idea but solve different problems. UART is the right answer for “I want a tty I can plug into screen /dev/ttyUSB0 and type into.” SPI is the right answer for high-speed synchronous transfers to a flash chip. USB is the right answer for hot-pluggable cables. On an FPGA SoC where the entire point is to bring up a kernel, the UART always comes first because nothing else can be debugged without it.

Production Notes

  • Linux drivers/tty/serial/8250/8250_core.c is the canonical software for the 16550. The file’s header comment lists support for “early_serial_setup() ports, userspace-configurable ‘phantom’ ports, serial8250_register_8250_port() ports” and the driver uses abstracted serial_in() / serial_out() accessors over the standard register constants UART_IER, UART_IIR, UART_LSR, UART_SCR, UART_LCR so the same code drives a port-I/O 16550 in a legacy PC and a memory-mapped 16550 on a RISC-V board. The interrupt handler iterates ports with a PASS_LIMIT of 512 to handle ISA shared-interrupt cases without infinite looping (Linux drivers/tty/serial/8250/8250_core.c, torvalds/linux).
  • OpenCores uart16550, by Igor Mohor and Tadej Markovic, is the most widely reused open-source 16550 IP core. It is “16550 compatible (mostly)” and ships with a Wishbone B3 interface, FIFO mode, and the standard interrupt set. It is the UART in countless small SoCs (OpenCores UART16550 project).
  • ZipCPU’s wbuart32 is a leaner alternative from Dan Gisselquist: a parameterized synchronous FIFO that supports up to 1024 entries, configurable baud / data bits / parity / stop bits, all set up “by just writing to and setting a single 32-bit word” rather than the 16550’s DLAB / LCR / IER dance. It does not implement the NS16550 register layout (so it is not a drop-in for the Linux 8250 driver), but it is much smaller and ships with a Verilator-based UART simulator for testing third-party implementations (ZipCPU/wbuart32 README).
  • QEMU virt instantiates a 16550A at base 0x1000_0000 with size 0x100 (QEMU hw/riscv/virt.c). Booting Linux on QEMU’s RISC-V virt sends all kernel printk output through that 16550, which is why a working 16550 driver is a prerequisite for any RISC-V boot.
  • In definitely-not-esp32 v1.0 the UART is a hand-written 16550-subset Verilog module sized to the Tang Nano 20K’s available BRAM (16-byte FIFOs each direction), wired to the Wishbone bus as a single slave at base 0x1000_0000. Its IRQ pin runs to the PLIC at source ID 10. The kernel’s first sign of life is a putc('B') from the early boot path; without the UART, the SoC is invisible.

See Also