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.
Port addresses
Section titled “Port addresses”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 name | Base address | Legacy IRQ |
|---|---|---|
| COM1 | 0x3F8 | 4 |
| COM2 | 0x2F8 | 3 |
| COM3 | 0x3E8 | 4 |
| COM4 | 0x2E8 | 3 |
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.
Register layout
Section titled “Register layout”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:
| Offset | Read | Write |
|---|---|---|
| +0 | Receive buffer (if DLAB=0) | Transmit buffer (if DLAB=0) |
| +0 | Divisor latch low byte (if DLAB=1) | Divisor latch low byte (if DLAB=1) |
| +1 | Interrupt enable (if DLAB=0) | Interrupt enable (if DLAB=0) |
| +1 | Divisor latch high byte (if DLAB=1) | Divisor latch high byte (if DLAB=1) |
| +2 | Interrupt identification | FIFO control |
| +3 | Line control | Line control |
| +4 | Modem control | Modem control |
| +5 | Line status | N/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.
Initialization
Section titled “Initialization”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.
Transmitting
Section titled “Transmitting”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);}Implementation notes
Section titled “Implementation notes”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.
References
Section titled “References”- ^ National Semiconductor, PC16550D Universal Asynchronous Receiver/Transmitter with FIFOs: the datasheet for the UART variant the register layout above is based on.
- ^ OSDev Wiki, “Serial Ports”: a hobbyist reference covering interrupt-driven serial I/O in more depth than fits here.
See also
Section titled “See also”- 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.