Project 7: Virtual Serial Port (UART) Emulator
Build a complete emulation of the 16550 UART (the standard PC serial port) that can connect a VM’s serial output to a host PTY or socket.
Quick Reference
| Attribute | Value |
|---|---|
| Difficulty | Intermediate (Level 2) |
| Time Estimate | 1-2 weeks |
| Language | C (alternatives: Rust, C++) |
| Prerequisites | Project 6 (Memory Mapper), understanding of hardware registers |
| Key Topics | Device emulation, UART protocol, FIFOs, interrupt generation, PTY/socket backends |
1. Learning Objectives
By completing this project, you will:
- Master device emulation patterns: Learn the register-based programming model that all hardware devices use, and how to emulate them in software
- Understand interrupt-driven I/O: Implement interrupt generation for data availability and transmission completion - the foundation of efficient I/O
- Build a production-quality device: The 16550 UART is used in every PC BIOS, Linux kernel, and embedded system - your emulator can boot real operating systems
- Connect virtual to physical: Learn how to bridge emulated devices to real host resources (PTY, sockets, files)
2. Theoretical Foundation
2.1 Core Concepts
What is a UART?
A UART (Universal Asynchronous Receiver/Transmitter) converts parallel data (bytes from the CPU) to serial data (bits on a wire) and vice versa. It’s the hardware behind serial ports, console output, and embedded debugging.
┌─────────────────────────────────────────────────────────────────┐
│ UART Operation │
├─────────────────────────────────────────────────────────────────┤
│ │
│ CPU/Bus Side Serial Line Side │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ │ TX: Write byte │ │ │
│ │ CPU writes │ ─────────────────────► │ Shift out │ │
│ │ to THR reg │ │ bit by bit │───►│
│ │ │ │ │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ │ RX: Read byte │ │ │
│ ◄──│ CPU reads │ ◄───────────────────── │ Shift in │◄───
│ │ from RBR │ │ bit by bit │ │
│ │ │ │ │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ Baud Rate: 115200 = 115200 bits/second │
│ At 8N1: 10 bits per byte → ~11,520 bytes/second │
│ │
└─────────────────────────────────────────────────────────────────┘
The 16550 UART Architecture
The 16550 is the standard PC UART, designed by National Semiconductor in 1987. It added 16-byte FIFOs to the earlier 8250, dramatically improving performance:
┌──────────────────────────────────────────────────────────────────────────┐
│ 16550 UART Block Diagram │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ CPU Bus Interface Serial Interface │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ Address Decode│ │ Baud Rate │ │
│ │ A0-A2, CS, RD │ │ Generator │ │
│ │ WR │ │ (Divisor) │ │
│ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Register File │ │
│ │ ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐│ │
│ │ │ THR │ RBR │ IER │ IIR │ FCR │ LCR │ MCR │ LSR ││ │
│ │ │ +0 │ +0 │ +1 │ +2 │ +2 │ +3 │ +4 │ +5 ││ │
│ │ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘│ │
│ │ ┌─────┬─────┐ │ │
│ │ │ MSR │ SCR │ │ │
│ │ │ +6 │ +7 │ │ │
│ │ └─────┴─────┘ │ │
│ └───────────────────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ TX FIFO │ │ RX FIFO │ │
│ │ 16 bytes │──────────────────► │ 16 bytes │ │
│ │ (THR side) │ TX Shift Reg │ (RBR side) │ │
│ └───────────────┘ └───────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ TX Shift │──────► TXD │ RX Shift │◄────── RXD │
│ │ Register │ │ Register │ │
│ └───────────────┘ └───────────────┘ │
│ │
│ Interrupt Logic: Combines all interrupt sources → IRQ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
Register Map and Bit Definitions
The 16550 has 8 registers mapped to consecutive I/O ports (or MMIO addresses):
| Offset | DLAB=0 Read | DLAB=0 Write | DLAB=1 | Description |
|---|---|---|---|---|
| +0 | RBR (Receive Buffer) | THR (Transmit Holding) | DLL (Divisor Low) | Data registers |
| +1 | IER (Interrupt Enable) | IER | DLM (Divisor High) | Interrupt control |
| +2 | IIR (Interrupt ID) | FCR (FIFO Control) | IIR | FIFO/Interrupt status |
| +3 | LCR (Line Control) | LCR | LCR | Data format |
| +4 | MCR (Modem Control) | MCR | MCR | Hardware flow control |
| +5 | LSR (Line Status) | - | LSR | Status flags |
| +6 | MSR (Modem Status) | - | MSR | Modem signals |
| +7 | SCR (Scratch) | SCR | SCR | General purpose |
┌──────────────────────────────────────────────────────────────────────────┐
│ Key Register Bit Definitions │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ IER - Interrupt Enable Register (offset +1) │
│ ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │
│ │ 7 │ 6 │ 5 │ 4 │ 3 │ 2 │ 1 │ 0 │ │
│ │ 0 │ 0 │ 0 │ 0 │MODEM│LINE │THRE │ RDA │ │
│ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ │
│ Bit 0: Receive Data Available interrupt enable │
│ Bit 1: Transmit Holding Register Empty interrupt enable │
│ Bit 2: Receiver Line Status interrupt enable │
│ Bit 3: Modem Status interrupt enable │
│ │
│ IIR - Interrupt Identification Register (offset +2, read) │
│ ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │
│ │ 7 │ 6 │ 5 │ 4 │ 3 │ 2 │ 1 │ 0 │ │
│ │FIFO │FIFO │ 0 │ 0 │ID2 │ID1 │ID0 │PEND │ │
│ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ │
│ Bit 0: Interrupt pending (0 = interrupt pending) │
│ Bits 1-3: Interrupt ID (priority encoded) │
│ 000 = Modem status, 001 = THR empty, 010 = RX data, 011 = Line status │
│ │
│ LSR - Line Status Register (offset +5) │
│ ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │
│ │ 7 │ 6 │ 5 │ 4 │ 3 │ 2 │ 1 │ 0 │ │
│ │FIFO │TEMT │THRE │BI │ FE │ PE │ OE │ DR │ │
│ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ │
│ Bit 0: Data Ready (RX FIFO has data) │
│ Bit 1: Overrun Error │
│ Bit 2: Parity Error │
│ Bit 3: Framing Error │
│ Bit 4: Break Interrupt │
│ Bit 5: THR Empty (TX FIFO empty) │
│ Bit 6: Transmitter Empty (TX FIFO and shift register empty) │
│ │
│ FCR - FIFO Control Register (offset +2, write) │
│ ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │
│ │ 7 │ 6 │ 5 │ 4 │ 3 │ 2 │ 1 │ 0 │ │
│ │TRIG1│TRIG0│ 0 │ 0 │DMA │TXRST│RXRST│FIFO │ │
│ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ │
│ Bit 0: Enable FIFOs │
│ Bit 1: Clear RX FIFO │
│ Bit 2: Clear TX FIFO │
│ Bits 6-7: RX FIFO trigger level (1, 4, 8, or 14 bytes) │
│ │
└──────────────────────────────────────────────────────────────────────────┘
Interrupt Handling Flow
The UART generates interrupts to notify the CPU about events. This is much more efficient than polling:
┌──────────────────────────────────────────────────────────────────────────┐
│ Interrupt Priority and Flow │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ Priority (highest to lowest): │
│ │
│ 1. Receiver Line Status Error (IIR = 0x06) │
│ └── Overrun, parity, framing error │
│ Cleared by: Reading LSR │
│ │
│ 2. Receiver Data Available (IIR = 0x04) │
│ └── RX FIFO >= trigger level │
│ Cleared by: Reading RBR until below trigger │
│ │
│ 3. Character Timeout (IIR = 0x0C) │
│ └── Data in RX FIFO, no read for 4 char times │
│ Cleared by: Reading RBR │
│ │
│ 4. Transmitter Holding Empty (IIR = 0x02) │
│ └── TX FIFO empty │
│ Cleared by: Writing to THR or reading IIR │
│ │
│ 5. Modem Status Change (IIR = 0x00) │
│ └── CTS, DSR, RI, DCD changed │
│ Cleared by: Reading MSR │
│ │
│ │
│ Interrupt Generation Logic: │
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Event │───►│ IER bit │───►┐ │
│ │ occurs │ │ enabled?│ │ │
│ └─────────┘ └─────────┘ │ │
│ ▼ │
│ ┌─────────┐ │
│ │ OR │─────► IRQ to CPU │
│ │ gate │ │
│ └─────────┘ │
│ ▲ │
│ ┌─────────┐ ┌─────────┐ │ │
│ │ Other │───►│ IER bit │───►┘ │
│ │ events │ │ enabled?│ │
│ └─────────┘ └─────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
2.2 Why This Matters
Universal debugging interface: Every kernel developer’s first output is the serial console. UART is how you debug when nothing else works - no graphics drivers, no network stack, just raw bytes.
Simplest “real” device: The UART is complex enough to teach device emulation patterns (registers, FIFOs, interrupts) but simple enough to implement in a week. It’s the “Hello World” of device emulation.
Production hypervisor component: QEMU’s serial device (hw/char/serial.c) is used by every VM running Linux. Your implementation will follow the same patterns.
Boot requirement: Many operating systems require a serial port for early boot messages. Your emulator enables booting real OSes.
2.3 Historical Context
1960s: Early teletypes used 110 baud serial communication. The RS-232 standard defined voltage levels.
1980s: The 8250 UART (IBM PC) had no FIFOs - the CPU had to read each byte immediately or risk overrun at high speeds.
1987: The 16550 added 16-byte FIFOs, enabling reliable operation at 115200 baud. This design has been essentially unchanged for 35+ years.
Today: While USB and networking have replaced serial for most uses, UART remains essential for embedded systems, server consoles (BMC/IPMI), and virtualization.
2.4 Common Misconceptions
Misconception 1: “Serial ports are obsolete”
- Reality: Every server has a serial console (accessible via BMC). Every embedded system uses UART for debugging. UART is fundamental infrastructure.
Misconception 2: “FIFOs are optional”
- Reality: Without FIFOs, the CPU must respond within one character time (~87 microseconds at 115200). FIFOs are essential for modern systems.
Misconception 3: “Baud rate is just software configuration”
- Reality: Both endpoints must agree on baud rate. Mismatched rates produce garbage. Your emulator doesn’t need to enforce timing, but understanding why it exists helps.
Misconception 4: “Reading IIR clears all interrupts”
- Reality: Each interrupt type has specific clearing conditions. Reading IIR only clears THRE (and not always). You must implement the exact clearing behavior.
3. Project Specification
3.1 What You Will Build
A complete 16550 UART emulator with:
- Full register emulation: All 8 registers with correct read/write behavior
- FIFO management: 16-byte TX and RX FIFOs with configurable trigger levels
- Interrupt generation: Priority-encoded interrupts with proper clearing
- Backend connectivity: Connect to PTY, socket, or file for real I/O
- Integration with memory manager: Works as an MMIO device from Project 6
3.2 Functional Requirements
- Register Operations
- THR/RBR: Transmit and receive data correctly
- IER: Enable/disable individual interrupt sources
- IIR: Report highest-priority pending interrupt
- FCR: Enable FIFOs, set trigger levels, clear FIFOs
- LCR: Configure data format (DLAB bit for divisor access)
- LSR: Report status (data ready, THR empty, errors)
- MCR/MSR: Basic modem control (loopback support)
- SCR: Scratch register (read/write pass-through)
- FIFO Behavior
- 16-byte RX FIFO with configurable trigger (1, 4, 8, 14 bytes)
- 16-byte TX FIFO
- Overrun detection when RX FIFO is full
- Clear FIFOs on FCR command
- Interrupt Generation
- Generate interrupts when enabled and conditions met
- Priority encoding: line status > RX data > TX empty > modem
- Correct clearing behavior for each interrupt type
- Character timeout interrupt (optional but recommended)
- Backend Support
- PTY backend: Bidirectional communication with host terminal
- Socket backend: TCP server for remote console
- File backend: Log TX to file, inject RX from file
3.3 Non-Functional Requirements
- Accuracy: Pass real OS driver testing (Linux serial driver should work)
- Performance: Handle 115200 baud equivalent throughput without delays
- Reliability: No data loss under normal operation
- Observability: Debug output showing register reads/writes
3.4 Example Usage / Output
$ ./uart_emulator --backend=pty
Virtual UART initialized
Backend: /dev/pts/5
Connect with: screen /dev/pts/5 115200
# In another terminal:
$ screen /dev/pts/5 115200
# In your guest OS:
[GUEST] echo "Hello from VM" > /dev/ttyS0
# In screen:
Hello from VM
With debug output enabled:
$ ./uart_emulator --backend=pty --debug
[UART] Register write: THR = 0x48 ('H')
[UART] TX FIFO: 1/16 bytes
[UART] Backend write: 'H'
[UART] Register read: LSR = 0x60 (THRE=1, TEMT=1)
[UART] Register write: THR = 0x65 ('e')
[UART] TX FIFO: 1/16 bytes
[UART] Backend write: 'e'
...
# When screen sends input:
[UART] Backend received: 'x'
[UART] RX FIFO: 1/16 bytes
[UART] LSR.DR set (data ready)
[UART] Interrupt: RX Data Available
[UART] Register read: IIR = 0x04 (RX data, priority 2)
[UART] Register read: RBR = 0x78 ('x')
[UART] RX FIFO: 0/16 bytes
[UART] LSR.DR cleared
Integration with memory mapper:
#include "memmap.h"
#include "uart.h"
// Create memory manager and UART
memmap_t *mm = memmap_create();
uart_t *uart = uart_create(irq_callback, irq_opaque);
// Register UART at standard COM1 address
memmap_add_mmio(mm, 0x3F8, 8, uart_mmio_handler, uart);
// Or at MMIO address for RISC-V/ARM
memmap_add_mmio(mm, 0x10000000, 8, uart_mmio_handler, uart);
// Connect backend
uart_set_backend_pty(uart);
printf("Connect with: screen %s 115200\n", uart_get_pty_path(uart));
// UART is now live - MMIO accesses will be handled
4. Solution Architecture
4.1 High-Level Design
┌─────────────────────────────────────────────────────────────────────────┐
│ UART Emulator Architecture │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Guest Software │ │
│ │ (Linux serial driver, BIOS, bare-metal code) │ │
│ └────────────────────────────────┬────────────────────────────────┘ │
│ │ │
│ ▼ MMIO read/write │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Memory Mapper (Project 6) │ │
│ │ MMIO trap at 0x3F8 / 0x10000000 │ │
│ └────────────────────────────────┬────────────────────────────────┘ │
│ │ │
│ ▼ uart_mmio_handler() │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ UART Core Emulator │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Register File│ │ TX/RX FIFOs │ │ Interrupt │ │ │
│ │ │ │ │ │ │ Logic │ │ │
│ │ │ THR,RBR,IER │ │ tx_fifo[16] │ │ │ │ │
│ │ │ IIR,FCR,LCR │ │ rx_fifo[16] │ │ Priority │ │ │
│ │ │ MCR,LSR,MSR │ │ Trigger lvl │ │ encoder │ │ │
│ │ │ SCR,DLL,DLM │ │ │ │ │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ TX complete │ │ IRQ callback │ │ │
│ │ │ RX available │ │ to VMM │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └────────────────────────────┬────────────────┬───────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Backend Interface │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ PTY Backend │ │Socket Backend│ │ File Backend │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ /dev/pts/N │ │ TCP:port │ │ tx.log │ │ │
│ │ │ Bidirectional│ │ Remote term │ │ rx_input.txt │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
4.2 Key Components
Register File
All UART state is stored in registers:
┌─────────────────────────────────────────────────────────────────┐
│ Register File State │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Core Registers: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ uint8_t ier; // Interrupt Enable │ │
│ │ uint8_t iir; // Interrupt ID (computed, not stored) │ │
│ │ uint8_t fcr; // FIFO Control (write-only effects) │ │
│ │ uint8_t lcr; // Line Control (includes DLAB bit) │ │
│ │ uint8_t mcr; // Modem Control │ │
│ │ uint8_t lsr; // Line Status (computed + sticky bits) │ │
│ │ uint8_t msr; // Modem Status │ │
│ │ uint8_t scr; // Scratch │ │
│ │ uint16_t divisor; // Baud rate divisor (DLL:DLM) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ FIFO State: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ uint8_t tx_fifo[16]; │ │
│ │ uint8_t rx_fifo[16]; │ │
│ │ int tx_head, tx_tail, tx_count; │ │
│ │ int rx_head, rx_tail, rx_count; │ │
│ │ bool fifos_enabled; │ │
│ │ int rx_trigger; // 1, 4, 8, or 14 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Interrupt State: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ bool thre_interrupt_pending; │ │
│ │ bool timeout_active; │ │
│ │ uint64_t last_rx_time; // For character timeout │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
FIFO Implementation
Circular buffer for efficient FIFO operations:
┌─────────────────────────────────────────────────────────────────┐
│ Circular Buffer FIFO │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Initial state (empty): │
│ ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
│ ▲ │
│ └── head = tail = 0, count = 0 │
│ │
│ After adding 'A', 'B', 'C': │
│ ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ │ A │ B │ C │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
│ ▲ ▲ │
│ │ └── tail = 3 │
│ └── head = 0, count = 3 │
│ │
│ After reading 'A': │
│ ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ │ │ B │ C │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
│ ▲ ▲ │
│ │ └── tail = 3 │
│ └── head = 1, count = 2 │
│ │
│ Wrapping (full buffer): │
│ ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ │ P │ Q │ R │ D │ E │ F │ G │ H │ I │ J │ K │ L │ M │ N │ O │ │
│ └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
│ ▲ ▲ │
│ │ └── head = 3 │
│ └── tail = 3 (would be if we add more) │
│ │
└─────────────────────────────────────────────────────────────────┘
Interrupt Logic
Computing IIR from current state:
┌─────────────────────────────────────────────────────────────────┐
│ Interrupt Computation Logic │
├─────────────────────────────────────────────────────────────────┤
│ │
│ uint8_t compute_iir(uart_t *uart) { │
│ // Check in priority order │
│ │
│ // Priority 1: Receiver Line Status │
│ if ((uart->ier & IER_RLSI) && │
│ (uart->lsr & (LSR_OE | LSR_PE | LSR_FE | LSR_BI))) │
│ return 0xC6; // FIFO enabled, Line Status │
│ │
│ // Priority 2: Receiver Data Available │
│ if ((uart->ier & IER_RDA) && │
│ (uart->rx_count >= uart->rx_trigger)) │
│ return 0xC4; // FIFO enabled, RX Data │
│ │
│ // Priority 3: Character Timeout │
│ if ((uart->ier & IER_RDA) && uart->timeout_active) │
│ return 0xCC; // FIFO enabled, Timeout │
│ │
│ // Priority 4: Transmitter Holding Empty │
│ if ((uart->ier & IER_THRE) && uart->thre_interrupt_pending)│
│ return 0xC2; // FIFO enabled, THRE │
│ │
│ // Priority 5: Modem Status │
│ if ((uart->ier & IER_MSI) && │
│ (uart->msr & (MSR_DCTS | MSR_DDSR | MSR_TERI | MSR_DDCD)))
│ return 0xC0; // FIFO enabled, Modem │
│ │
│ // No interrupt pending │
│ return 0xC1; // FIFO enabled, no interrupt │
│ } │
│ │
└─────────────────────────────────────────────────────────────────┘
4.3 Data Structures
// FIFO structure
typedef struct uart_fifo {
uint8_t data[16];
int head; // Read position
int tail; // Write position
int count; // Current count
} uart_fifo_t;
// Backend type
typedef enum {
BACKEND_NONE,
BACKEND_PTY,
BACKEND_SOCKET,
BACKEND_FILE
} uart_backend_type_t;
// Interrupt callback type
typedef void (*uart_irq_callback_t)(void *opaque, int level);
// Main UART state
typedef struct uart {
// Registers
uint8_t ier; // Interrupt Enable
uint8_t lcr; // Line Control
uint8_t mcr; // Modem Control
uint8_t msr; // Modem Status
uint8_t scr; // Scratch
uint16_t divisor; // Baud rate divisor
// FIFOs
uart_fifo_t tx_fifo;
uart_fifo_t rx_fifo;
bool fifos_enabled;
int rx_trigger; // 1, 4, 8, or 14
// Interrupt state
bool thre_int_pending;
bool timeout_active;
// Backend
uart_backend_type_t backend_type;
int backend_fd; // PTY master fd or socket fd
char pty_path[64]; // Path to PTY slave
// IRQ callback
uart_irq_callback_t irq_callback;
void *irq_opaque;
// Statistics
uint64_t tx_bytes;
uint64_t rx_bytes;
} uart_t;
4.4 Algorithm Overview
Register Read Operation:
Input: Register offset (0-7)
Output: Register value (8-bit)
1. If DLAB=1 and offset < 2:
- Return divisor byte (DLL or DLM)
2. Else switch on offset:
- 0: Return front of RX FIFO (pop), update LSR
- 1: Return IER
- 2: Return computed IIR, clear THRE pending if applicable
- 3: Return LCR
- 4: Return MCR
- 5: Return computed LSR
- 6: Return MSR, clear delta bits
- 7: Return SCR
3. Update interrupt state
4. Return value
Register Write Operation:
Input: Register offset (0-7), value (8-bit)
1. If DLAB=1 and offset < 2:
- Store divisor byte (DLL or DLM)
2. Else switch on offset:
- 0: Push to TX FIFO, send to backend
- 1: Store IER, re-evaluate interrupts
- 2: Process FCR (enable FIFOs, clear, set trigger)
- 3: Store LCR
- 4: Store MCR, handle loopback
- 5: N/A (read-only)
- 6: N/A (read-only)
- 7: Store SCR
3. Update interrupt state
Backend TX Flow:
When THR written:
1. Add byte to TX FIFO (if not full)
2. If backend connected:
- Write all TX FIFO data to backend fd
- Clear TX FIFO
3. Update LSR (THRE=1 if FIFO empty, TEMT=1 if shift reg empty)
4. If IER.THRE enabled and FIFO now empty:
- Set THRE interrupt pending
- Call IRQ callback(1) to raise interrupt
Backend RX Flow:
When backend has data (polled or async):
1. Read byte(s) from backend fd
2. For each byte:
- If RX FIFO not full:
- Add to RX FIFO
- Else:
- Set LSR.OE (overrun error)
3. Update LSR.DR (data ready)
4. If RX count >= trigger and IER.RDA enabled:
- Call IRQ callback(1) to raise interrupt
5. Implementation Guide
5.1 Development Environment Setup
Required packages:
# Ubuntu/Debian
sudo apt install build-essential gdb
# For testing with screen
sudo apt install screen
# For socket testing
sudo apt install netcat-openbsd
Test with real serial terminal:
# Your emulator creates a PTY
./uart_emulator --backend=pty
# Output: Backend: /dev/pts/5
# Connect with screen
screen /dev/pts/5 115200
# Or with minicom
minicom -D /dev/pts/5 -b 115200
5.2 Project Structure
uart_project/
├── Makefile
├── include/
│ └── uart.h # Public API
├── src/
│ ├── uart.c # Core UART emulation
│ ├── uart_regs.c # Register read/write handlers
│ ├── uart_fifo.c # FIFO implementation
│ ├── uart_irq.c # Interrupt logic
│ └── backends/
│ ├── pty.c # PTY backend
│ ├── socket.c # Socket backend
│ └── file.c # File backend
├── tests/
│ ├── test_regs.c # Register operation tests
│ ├── test_fifo.c # FIFO tests
│ ├── test_irq.c # Interrupt tests
│ └── test_integration.c # Full integration tests
└── examples/
└── standalone_demo.c # Demo without memory mapper
5.3 The Core Question You’re Answering
“How does a piece of hardware communicate with software through registers, FIFOs, and interrupts, and how do we recreate that behavior in software?”
This is the fundamental device emulation question. Your UART emulator must:
- Present the same register interface as real hardware
- Maintain the same internal state machine
- Generate interrupts at the correct times
- Handle the timing and ordering that software expects
5.4 Concepts You Must Understand First
Question 1: What is memory-mapped I/O (MMIO)? How does it differ from port I/O?
- Reference: “Computer Systems: A Programmer’s Perspective” Chapter 6
Question 2: What is a circular buffer? How do you implement it without data loss?
- Reference: Standard algorithms textbook or “The Art of Computer Programming”
Question 3: What is interrupt-driven I/O? Why is it better than polling?
- Reference: “Operating Systems: Three Easy Pieces” Chapters on I/O
Question 4: What is a PTY (pseudo-terminal)? How do master/slave sides work?
- Reference: “The Linux Programming Interface” Chapter 64
Question 5: What is edge-triggered vs. level-triggered interrupts?
- Reference: “Linux Device Drivers” Chapter 10
5.5 Questions to Guide Your Design
Register Design:
- How do you handle registers that behave differently on read vs. write (IIR/FCR)?
- What happens when the guest reads LSR? Which bits are sticky, which are cleared?
- How do you implement the DLAB (Divisor Latch Access Bit) dual-use of registers 0 and 1?
FIFO Design:
- What happens when the guest writes to THR but TX FIFO is full?
- How do you handle the case where FIFOs are disabled (8250 compatibility mode)?
- What is the character timeout interrupt and when should it fire?
Interrupt Design:
- When exactly should THRE interrupt become pending?
- What clears the THRE interrupt? (Hint: it’s subtle)
- How do you implement interrupt priority without scanning all conditions every time?
Backend Design:
- How do you make the backend non-blocking?
- What if the host terminal is not reading (backpressure)?
- How do you inject received data into the RX FIFO asynchronously?
5.6 Thinking Exercise
Before coding, trace through this sequence by hand:
Scenario: Guest initializes UART and sends “Hi”:
1. Guest writes FCR = 0x07 (enable FIFOs, clear both)
2. Guest writes LCR = 0x03 (8N1 format)
3. Guest writes IER = 0x03 (enable RDA and THRE interrupts)
4. Guest reads LSR → expects THRE=1, TEMT=1
5. Guest writes THR = 'H'
6. Guest reads IIR → what is the value?
7. Guest writes THR = 'i'
8. Guest reads LSR → what is the value?
For each step, track:
- What state changes in the UART?
- Are any interrupts generated?
- What values are returned for reads?
Expected behavior:
- FCR: FIFOs enabled, both cleared, trigger=1
- LCR: 8 data bits, no parity, 1 stop bit
- IER: RDA and THRE interrupts enabled. THRE interrupt immediately pending (TX empty)!
- LSR read: Returns 0x60 (THRE=1 bit 5, TEMT=1 bit 6)
- THR write: ‘H’ sent to backend, TX FIFO momentarily had 1 byte then 0. THRE interrupt pending again.
- IIR read: Returns 0x02 (THRE interrupt). Reading IIR clears THRE pending (controversial - check spec carefully)
- THR write: ‘i’ sent to backend
- LSR read: Returns 0x60 (still empty)
5.7 Hints in Layers
Hint 1 - Starting Point (Conceptual Direction): Start with just the TX path. Implement THR writes that output to stdout. Get a guest able to print characters. Then add FIFOs, then interrupts, then the RX path.
Hint 2 - Next Level (More Specific): The THRE interrupt has tricky behavior. According to the 16550 spec, THRE becomes pending when THR becomes empty. But reading IIR (when THRE is the highest priority interrupt) clears it. Writing to THR also resets the pending state.
Hint 3 - Technical Details (Approach): For the PTY backend:
int master_fd = posix_openpt(O_RDWR | O_NOCTTY);
grantpt(master_fd);
unlockpt(master_fd);
char *slave_path = ptsname(master_fd);
// slave_path is what users connect to with screen
// master_fd is what you read/write for I/O
Hint 4 - Tools/Debugging (Verification): Test with a simple Linux serial driver test:
# In guest (if you have a simple OS):
echo "test" > /dev/ttyS0
# Or use screen's ~f command to send a file:
# In screen session: Ctrl-A, :, readbuf /path/to/file, Ctrl-A, ]
Compare your output with real QEMU UART behavior by running the same guest.
5.8 The Interview Questions They’ll Ask
- “How does the 16550 UART handle overrun conditions?”
- When RX FIFO is full and new byte arrives, the byte is lost
- LSR.OE (Overrun Error) bit is set
- Guest must read LSR to clear the bit
- Receiver Line Status interrupt fires if enabled
- “Explain the character timeout interrupt in the 16550”
- After data enters RX FIFO, if no more data and no reads for 4 character times
- Character timeout interrupt fires (even if below trigger level)
- This ensures data doesn’t sit in FIFO indefinitely
- Your emulator can simplify this with a timer
- “What’s the difference between THR and the TX FIFO?”
- THR is the register interface (address +0, write)
- TX FIFO is the internal buffer
- In 16550 with FIFOs enabled, THR writes go to FIFO
- In 8250 mode (FIFOs disabled), THR is effectively a 1-byte FIFO
- “How would you handle loopback mode (MCR bit 4)?”
- TX output loops back to RX input
- MCR bits 0-1 (DTR, RTS) loop back to MSR bits 4-5 (CTS, DSR)
- Used for self-test without external cable
- “Why does QEMU serial emulation need to be async?”
- Backend I/O (PTY, socket) might block
- Guest can’t wait for slow terminal
- Use non-blocking I/O and event loops
- This is why QEMU uses its main loop/coroutines
5.9 Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| 16550 UART | 16550 Datasheet | Full document |
| Device registers | “Linux Device Drivers” by Corbet | Chapter 9 |
| Interrupt handling | “Linux Device Drivers” by Corbet | Chapter 10 |
| PTY programming | “The Linux Programming Interface” by Kerrisk | Chapter 64 |
| QEMU device model | “QEMU Documentation” | Device model guide |
5.10 Implementation Phases
Phase 1: Core Registers (Day 1-2)
- Implement uart_create() and uart_destroy()
- Implement basic register read/write (SCR, LCR as test cases)
- Add THR write that outputs to stdout
- Test: Write bytes to THR, see them on stdout
Phase 2: TX Path with FIFO (Day 2-3)
- Implement circular buffer FIFO
- Add FCR handling (enable FIFOs, clear)
- Implement proper LSR (THRE, TEMT bits)
- Test: Write multiple bytes rapidly, verify FIFO behavior
Phase 3: PTY Backend (Day 3-4)
- Implement PTY creation and management
- Connect TX path to PTY write
- Test: screen connects and receives data
Phase 4: RX Path (Day 4-5)
- Implement RX FIFO
- Add backend polling for incoming data
- Update LSR (DR bit)
- Test: Type in screen, read with RBR
Phase 5: Interrupts (Day 5-7)
- Implement IER handling
- Implement IIR computation with priority
- Add interrupt callback mechanism
- Test: Verify interrupts fire at correct times
Phase 6: Polish (Day 7-10)
- Add DLL/DLM divisor access (DLAB)
- Implement MCR/MSR (at least loopback)
- Add character timeout (optional)
- Comprehensive testing
5.11 Key Implementation Decisions
Decision 1: FIFO always enabled vs. 8250 compatibility
- Options: Always enable FIFOs, support both modes
- Recommendation: Start with FIFOs always enabled. Add 8250 mode if needed for specific guest compatibility.
Decision 2: Blocking vs. non-blocking I/O
- Options: Blocking with threads, non-blocking with poll
- Recommendation: Use non-blocking I/O with poll()/select() for simplicity in single-threaded emulator.
Decision 3: Interrupt delivery mechanism
- Options: Callback, shared state, signal
- Recommendation: Use callback function. This is how QEMU does it and allows clean integration with any VMM.
Decision 4: Strict timing vs. instant
- Options: Simulate baud rate delays, instant transmission
- Recommendation: Instant transmission. Real delays are unnecessary for functional emulation.
6. Testing Strategy
Unit Tests
// test_fifo.c
void test_fifo_operations(void) {
uart_fifo_t fifo;
fifo_init(&fifo);
// Test push
assert(fifo_push(&fifo, 'A') == 0);
assert(fifo.count == 1);
// Test pop
uint8_t val;
assert(fifo_pop(&fifo, &val) == 0);
assert(val == 'A');
assert(fifo.count == 0);
// Test full
for (int i = 0; i < 16; i++)
fifo_push(&fifo, i);
assert(fifo_is_full(&fifo));
assert(fifo_push(&fifo, 99) == -1); // Should fail
// Test wrap-around
for (int i = 0; i < 8; i++)
fifo_pop(&fifo, &val);
for (int i = 0; i < 8; i++)
fifo_push(&fifo, i + 100);
// Now data wraps around buffer end
printf("test_fifo_operations: PASSED\n");
}
// test_regs.c
void test_lcr_register(void) {
uart_t *uart = uart_create(NULL, NULL);
// Write LCR
uart_write(uart, 3, 0x83); // 8N1 + DLAB
assert(uart_read(uart, 3) == 0x83);
// Verify DLAB affects registers 0 and 1
uart_write(uart, 0, 0x01); // DLL
uart_write(uart, 1, 0x00); // DLM
assert(uart->divisor == 1); // 115200 baud
// Clear DLAB
uart_write(uart, 3, 0x03);
// Now offset 0 is THR/RBR again
uart_destroy(uart);
printf("test_lcr_register: PASSED\n");
}
Integration Tests
// test_integration.c
void test_tx_to_pty(void) {
uart_t *uart = uart_create(mock_irq, NULL);
uart_set_backend_pty(uart);
// Open PTY slave for reading
int slave_fd = open(uart_get_pty_path(uart), O_RDONLY | O_NOCTTY);
assert(slave_fd >= 0);
// Send data through UART
uart_write(uart, 3, 0x03); // LCR: 8N1
uart_write(uart, 2, 0x07); // FCR: enable FIFOs
uart_write(uart, 0, 'H');
uart_write(uart, 0, 'i');
// Read from PTY slave
char buf[10];
int n = read(slave_fd, buf, sizeof(buf));
assert(n == 2);
assert(buf[0] == 'H' && buf[1] == 'i');
close(slave_fd);
uart_destroy(uart);
printf("test_tx_to_pty: PASSED\n");
}
void test_rx_interrupt(void) {
int irq_count = 0;
uart_t *uart = uart_create(count_irq, &irq_count);
uart_set_backend_pty(uart);
// Enable FIFO and RDA interrupt
uart_write(uart, 2, 0x07);
uart_write(uart, 1, 0x01); // IER: RDA enabled
// Inject data into RX FIFO (simulating backend)
uart_inject_rx(uart, 'X');
// Should have triggered interrupt
assert(irq_count == 1);
// Read IIR
uint8_t iir = uart_read(uart, 2);
assert((iir & 0x0F) == 0x04); // RX data available
// Read RBR
uint8_t data = uart_read(uart, 0);
assert(data == 'X');
uart_destroy(uart);
printf("test_rx_interrupt: PASSED\n");
}
System Tests
// Test with real Linux kernel (requires full VM setup)
void test_linux_serial_driver(void) {
// This test would boot a Linux kernel and verify:
// 1. Kernel detects ttyS0
// 2. printk output appears
// 3. Can login on serial console
// 4. stty settings work
// For unit testing, use a simple bare-metal program instead
printf("test_linux_serial_driver: MANUAL TEST\n");
}
7. Common Pitfalls & Debugging
| Problem | Root Cause | Fix | Verification |
|---|---|---|---|
| No output from UART | THR writes not reaching backend | Check backend fd is valid, check FIFO not full | Add debug print on THR write |
| Guest hangs waiting for THR empty | LSR.THRE not being set | Set THRE immediately after TX FIFO drains | Print LSR value on each read |
| Interrupts not firing | IER not enabled or callback not set | Verify IER value, check callback != NULL | Print IER and interrupt state |
| Garbled output | Backend PTY not in raw mode | May need to configure PTY terminal settings | Use screen which handles this |
| RX data lost | RX FIFO overflow, not polling backend | Poll backend more frequently, check FIFO size | Monitor rx_count |
| THRE interrupt keeps firing | Not clearing THRE pending correctly | Reading IIR should clear if THRE is top priority | Trace IIR reads |
| screen shows nothing | PTY not opened correctly | Verify ptsname() returns valid path | Test with cat < /dev/pts/N |
Debugging Techniques
Add register trace:
void uart_write(uart_t *uart, int offset, uint8_t value) {
const char *reg_names[] = {
"THR/DLL", "IER/DLM", "FCR", "LCR",
"MCR", "(ro)", "(ro)", "SCR"
};
printf("[UART] Write %s = 0x%02x\n", reg_names[offset], value);
// ... rest of implementation
}
Dump UART state:
void uart_dump_state(uart_t *uart) {
printf("UART State:\n");
printf(" IER: 0x%02x LCR: 0x%02x MCR: 0x%02x\n",
uart->ier, uart->lcr, uart->mcr);
printf(" LSR: 0x%02x MSR: 0x%02x\n",
compute_lsr(uart), uart->msr);
printf(" TX FIFO: %d/16 RX FIFO: %d/16\n",
uart->tx_fifo.count, uart->rx_fifo.count);
printf(" THRE pending: %d\n", uart->thre_int_pending);
}
Compare with QEMU:
# Run same guest in QEMU with serial tracing
qemu-system-x86_64 -serial stdio -d guest_errors \
-trace 'serial*' ...
# Or use QEMU monitor
(qemu) info qtree # Shows serial device state
8. Extensions & Challenges
Basic Extensions
- Socket backend: Accept TCP connections for remote console access
./uart_emulator --backend=socket:12345 # Connect with: nc localhost 12345 - File logging: Log all TX data to a file for debugging
./uart_emulator --backend=pty --log=uart.log - Multiple UARTs: Support COM1-COM4 (4 independent UARTs)
Intermediate Challenges
- Modem control signals: Implement full MCR/MSR with flow control
- DTR/DSR handshaking
- RTS/CTS hardware flow control
- RI (Ring Indicator) for modem emulation
-
Break condition: Implement break signal generation and detection
- Baud rate enforcement: Add optional delays to simulate real baud rates (for testing timing-sensitive code)
Advanced Challenges
-
16750/16950 emulation: Extend to 64-byte FIFOs and additional features
- virtio-console comparison: Implement virtio-console and compare with UART
- virtio-console is more efficient (no register emulation)
- UART is more compatible (works with any OS)
- UART passthrough: Connect virtual UART directly to host serial port
./uart_emulator --backend=serial:/dev/ttyUSB0
9. Real-World Connections
QEMU Serial Implementation
QEMU’s serial device (hw/char/serial.c) is remarkably similar to what you’re building:
// QEMU's register write handler (simplified)
static void serial_ioport_write(void *opaque, hwaddr addr,
uint64_t val, unsigned size) {
SerialState *s = opaque;
switch (addr) {
case 0:
if (s->lcr & UART_LCR_DLAB) {
s->divider = (s->divider & 0xff00) | val;
} else {
// Transmit character
s->thr = val;
// ...
}
break;
// ...
}
}
After completing this project, QEMU’s serial.c will be readable and familiar.
Linux Kernel Serial Driver
The Linux kernel’s 8250 driver (drivers/tty/serial/8250/) talks to your emulator. Key functions:
serial8250_tx_chars(): Sends characters by writing THRserial8250_rx_chars(): Reads characters from RBRserial8250_handle_irq(): Handles UART interrupts
Your emulator must behave correctly for this driver to work.
Firecracker and Cloud Hypervisor
Both use UART for serial console:
- Firecracker:
src/devices/src/legacy/serial.rs - Cloud Hypervisor:
devices/src/legacy/serial.rs
These Rust implementations follow the same patterns you’re learning.
10. Resources
Essential Reading
- 16550 Datasheet: https://www.ti.com/lit/ds/symlink/pc16550d.pdf (the primary reference)
- OSDev UART: https://wiki.osdev.org/Serial_Ports
- Linux serial driver: https://github.com/torvalds/linux/blob/master/drivers/tty/serial/8250/8250_port.c
Code References
- QEMU serial: https://github.com/qemu/qemu/blob/master/hw/char/serial.c
- Firecracker serial: https://github.com/firecracker-microvm/firecracker/blob/main/src/devices/src/legacy/serial.rs
- SeaBIOS serial: https://github.com/coreboot/seabios/blob/master/src/hw/serialio.c
Tutorials
- Writing a UART driver: https://www.lammertbies.nl/comm/info/serial-uart
- PTY programming: https://man7.org/linux/man-pages/man7/pty.7.html
11. Self-Assessment Checklist
Before moving on, verify you can:
- Explain the 16550 register map and what each register does
- Describe how FIFOs improve UART performance over the 8250
- Implement a circular buffer without data loss or corruption
- Explain the UART interrupt priority scheme and clearing conditions
- Create and use a PTY for bidirectional communication
- Debug UART issues by examining register state and FIFO contents
- Describe how Linux’s 8250 driver interacts with UART hardware
12. Submission / Completion Criteria
Your project is complete when:
- Register Emulation Works
- All 8 registers behave correctly (read/write/side effects)
- DLAB controls register 0 and 1 dual-function
- LSR accurately reflects FIFO state
- FIFOs Work
- TX FIFO buffers outgoing data
- RX FIFO buffers incoming data
- FCR can enable/disable and clear FIFOs
- Trigger level affects interrupt generation
- Interrupts Work
- RDA interrupt fires when RX FIFO >= trigger
- THRE interrupt fires when TX FIFO empties
- IIR reports correct priority-encoded value
- Interrupts clear correctly per specification
- Backend Works
- PTY backend allows screen/minicom connection
- Bidirectional communication works
- No data loss under normal operation
- Integration Works
- Can be registered as MMIO device with Project 6
- Simple bare-metal program can use the UART
- (Bonus) Linux kernel boot messages appear
Demonstration:
$ ./uart_demo
[UART] Backend: /dev/pts/5
[UART] Connect with: screen /dev/pts/5 115200
# In another terminal:
$ screen /dev/pts/5 115200
# Type characters and see them echoed
# Run test program that does more complex I/O
After completing this project, you’ll have built a device emulator that’s production-quality enough to boot real operating systems. This understanding transfers directly to implementing other devices (block, network, GPU) and to contributing to QEMU, Firecracker, or any VMM that needs device emulation.