Skip to content

Serial Port

The serial port, driven by a UART (Universal Asynchronous Receiver/Transmitter) chip, is one of the simplest pieces of hardware an x86 kernel can talk to, which is precisely what makes it valuable well before it is otherwise useful: long before a kernel has a working VGA driver, a filesystem, or even reliable interrupt handling, a serial port can already be transmitting readable debug output, both to a physical serial cable and, inside essentially every emulator, to the host terminal directly with no physical wiring involved at all.

Serial ports on a PC-compatible machine are conventionally exposed at a small number of fixed I/O port ranges, each spanning eight consecutive ports from a base address:

Port nameBase addressLegacy IRQ
COM10x3F84
COM20x2F83
COM30x3E84
COM40x2E83

COM1, at base 0x3F8, is by far the most commonly used for kernel debug output, both because it is the most reliably present and because QEMU and most other emulators map it directly to the host’s terminal or a log file by default, requiring no guest-side configuration beyond programming the UART itself.

The eight ports starting at a UART’s base address are not eight independent registers in the ordinary sense: several are reused for different purposes depending on a control bit, a legacy of fitting a fairly capable chip’s functionality into a small number of I/O addresses:

OffsetReadWrite
+0Receive buffer (if DLAB=0)Transmit buffer (if DLAB=0)
+0Divisor latch low byte (if DLAB=1)Divisor latch low byte (if DLAB=1)
+1Interrupt enable (if DLAB=0)Interrupt enable (if DLAB=0)
+1Divisor latch high byte (if DLAB=1)Divisor latch high byte (if DLAB=1)
+2Interrupt identificationFIFO control
+3Line controlLine control
+4Modem controlModem control
+5Line statusN/A

The DLAB (Divisor Latch Access Bit), bit 7 of the Line Control register, is what governs this reuse: with it set, the two lowest ports temporarily expose the baud rate divisor instead of the transmit/receive buffer and interrupt enable registers, and must be cleared again afterward to restore normal data transfer.

A minimal initialization sequence disables interrupts (for a polling-only implementation), sets the baud rate divisor, and configures the line format:

void serial_init(uint16_t port) {
outb(port + 1, 0x00); // disable interrupts
outb(port + 3, 0x80); // set DLAB to access divisor latch
outb(port + 0, 0x03); // divisor low byte: 3 -> 38400 baud (115200 / 3)
outb(port + 1, 0x00); // divisor high byte
outb(port + 3, 0x03); // 8 bits, no parity, 1 stop bit; clears DLAB
outb(port + 2, 0xC7); // enable FIFO, clear it, 14-byte threshold
outb(port + 4, 0x0B); // enable IRQ pin, RTS/DSR set
}

The divisor sets the baud rate as a fraction of the UART’s fixed internal clock (conventionally 115200 for the standard PC-compatible UART), so a divisor of 3 yields 115200⁄3, or 38400 baud; both ends of a serial connection must agree on baud rate, and on the line format configured in the same step (data bits, parity, stop bits), or received data will be garbled even though the electrical connection itself is functioning correctly.

Sending a byte requires waiting until the UART’s transmit holding register is actually empty before writing to it, checked through bit 5 of the Line Status register: writing before the previous byte has been accepted overwrites it rather than queuing behind it, since the raw transmit buffer holds only a single byte outside of FIFO buffering:

void serial_write_char(uint16_t port, char c) {
while (!(inb(port + 5) & 0x20)) { } // wait for Transmit Holding Register Empty
outb(port, c);
}

A serial port makes an unusually good early debugging tool precisely because it needs almost nothing else working first: no interrupt handling if a purely polling implementation is used, no memory management beyond a few bytes of stack, and no display hardware at all, which makes it common for a kernel’s very first line of output anywhere to go out over COM1 rather than to a framebuffer. QEMU in particular can be told to redirect a guest’s COM1 output directly to the host terminal (-serial stdio or equivalent), turning what would otherwise require a physical null-modem cable and a second machine into a single command-line flag, and making a working serial driver one of the highest-value pieces of very early kernel code to get right.

A frequent early mistake is forgetting that the divisor latch registers alias the transmit/receive buffer and interrupt enable registers: code that sets DLAB to configure the baud rate and then forgets to clear it again before attempting to send a byte will instead write into the divisor latch, silently corrupting the baud rate configuration rather than transmitting anything.

  1. ^ National Semiconductor, PC16550D Universal Asynchronous Receiver/Transmitter with FIFOs: the datasheet for the UART variant the register layout above is based on.
  2. ^ OSDev Wiki, “Serial Ports”: a hobbyist reference covering interrupt-driven serial I/O in more depth than fits here.
  • PIC & APIC: for driving the serial port through IRQ4 rather than by polling.
  • VGA & Framebuffers: the display output path a serial console is usually a precursor to.
  • Console and Terminal Emulation: the same escape-sequence parsing applied in the opposite direction, to sequences a serial console receives.