Programming a PC UART: Register Banking, Baud Divisors, FIFOs, and Status
Configure a known 8250-compatible UART safely by managing DLAB, divisor latches, interrupt state, FIFOs, modem signals, and line errors.
Programming a PC UART directly gives a DOS application more control than the BIOS INT 14h interface, but it also transfers responsibility for port ownership, divisor programming, FIFO behavior, interrupts, and error handling to the application. A familiar COM port name is not proof that an 8250-compatible UART is present at a particular address. Base ports, IRQ routing, bridges, and emulator devices vary; probe and document the exact target before issuing hardware writes.
This article focuses on the common 8250/16450-compatible register model and the 16550D FIFO additions. Clone UARTs may differ, and many modern systems expose serial ports through Super I/O chips, USB adapters, or virtual devices. Treat the chip datasheet and machine-specific I/O map as the authority. Do not assume that direct port I/O works on a BIOS-only USB serial adapter.
Establish port ownership and electrical boundaries
The traditional PC assignment for COM1 is base I/O address 03F8h and commonly IRQ4; COM2 is often 02F8h and IRQ3. These are conventions, not a guarantee that the machine has those devices enabled or that an operating system has left them unclaimed. Inspect BIOS setup, the platform’s device map, and any installed driver before using a port. Do not reconfigure a UART that another TSR, serial driver, modem, or network stack currently owns.
A UART is a digital logic interface. An RS-232 connector normally includes a line transceiver that converts logic levels to the bipolar voltage signaling used on the cable. Do not connect a UART’s TTL-level pins directly to an RS-232 cable, and do not assume a DB-9 connector’s signals are safe to probe with arbitrary wiring. Use the board documentation, an electrically appropriate adapter, and a known loopback plug.
Record the input clock, port base, IRQ, connector pinout, cable type, and external transceiver before programming. The PC16550D datasheet describes a UART device, not a complete motherboard implementation. The surrounding platform decides address decoding, clock source, interrupt wiring, and electrical interface.
The register bank changes when DLAB changes
The common UART map uses offsets from the selected base. At offset 0, reads access the receiver buffer register and writes access the transmitter holding register. Offset 1 is the interrupt enable register. When the line-control register’s DLAB bit is set, offsets 0 and 1 instead expose the divisor latch low and high bytes. Offset 2 is the interrupt-identification register on reads and FIFO-control register on writes. Other familiar registers include line control at offset 3, modem control at offset 4, line status at offset 5, and modem status at offset 6.
That aliasing makes initialization order a correctness requirement. If DLAB is left set, a write intended for the interrupt-enable register changes the high divisor byte instead. If it is cleared too early, divisor data can be sent as a character. A robust routine first disables UART interrupts, preserves or records the previous line-control state, sets DLAB, writes both divisor bytes, clears DLAB by writing the final line framing, and only then configures FIFOs and enables intended interrupts.
The line-control register selects data bits, stop-bit behavior, parity, and DLAB. A common configuration is 8 data bits, no parity, one stop bit, represented by LCR=03h with DLAB clear. That byte describes only framing. It does not configure the clock, cable, flow control, or the remote endpoint.
Calculate and validate the baud divisor
For the standard 16-times oversampling clock in a typical PC-compatible UART, the divisor is approximately:
divisor = UART input clock / (16 × requested baud rate)
With the conventional 1.8432 MHz input and 9600 baud, the divisor is 12. The low byte goes to the divisor-latch-low register and the high byte to divisor-latch-high while DLAB is set. This arithmetic does not prove the board uses that input clock. A different oscillator or an adapter with a different clocking scheme yields a different actual baud rate.
After initialization, measure the bit timing against a known peer or use a serial analyzer. A successful loopback only proves the local transmit-to-receive path under that test; it does not prove the remote device uses the same framing or baud rate. Capture bytes with a pattern that includes zero, high-bit values, and repeated characters, and compare the received bytes rather than relying on readable terminal output.
Use FIFOs as queues, not as a loss guarantee
The PC16550D adds transmit and receive FIFOs intended to reduce CPU servicing frequency. Its FIFO-control register can enable and reset the queues and select receive trigger thresholds. The FIFO depth and trigger interpretation must be checked for the exact device; older 8250/16450-compatible parts do not provide equivalent FIFOs, and not every later clone behaves identically.
An example for a verified PC16550D uses FCR=C7h: FIFO enable, receive and transmit FIFO reset bits, and the documented receive trigger selection. This is an example of a specific device, not a universal byte to send to every UART. A safe driver detects or is configured for the actual chip and tests FIFO capability before depending on batching.
FIFOs do not eliminate overrun. If software leaves data unread longer than the receive queue can hold it, new characters can be lost and line-status registers can report an overrun condition. Select a threshold that balances interrupt frequency and latency. A high threshold reduces interrupt load but increases the amount of data that can be lost if the consumer is delayed. A low threshold may improve response but produce more interrupts.
Read status before blaming the cable
The line-status register exposes data-ready, overrun, parity, framing, break, transmitter-holding-empty, and transmitter-empty conditions on the common model. Distinguish “holding register empty” from “transmitter fully empty”: a byte may have left the software-visible queue but still be shifting out of the UART. When controlling direction-sensitive half-duplex equipment, waiting on the wrong status can switch direction before the final stop bit leaves the pin.
The modem-control and modem-status registers handle signals such as DTR, RTS, CTS, DSR, RI, and carrier detect. A null-modem cable, USB serial adapter, or virtual socket may wire or emulate these signals differently. If a program waits for carrier or CTS that the peer never asserts, a correct baud setting will not make the transfer progress. Test with flow control explicitly defined at both ends.
Read and clear status according to the chip’s documented semantics. Some status bits are cleared by reading a particular register or by reading the associated data. Logging only the byte value without its register and timing can destroy diagnostic context. Keep a trace of line status before and after reads when investigating an overrun or framing issue.
Minimal polling-mode setup for a verified PC16550D
This 8086-compatible assembly fragment configures a known PC16550D at COM1 for 9600 baud and 8N1, disables interrupts, and enables its FIFOs. It does not discover the port, save the prior configuration, enable IRQ handling, or prove that COM1 is free. Integrate it only in a controlled program with a restoration path:
COM1_BASE equ 03F8h
mov dx, COM1_BASE + 1 ; IER while DLAB is clear
xor al, al
out dx, al ; disable UART interrupts
mov dx, COM1_BASE + 3 ; LCR
mov al, 83h ; DLAB=1 plus 8N1 framing bits
out dx, al
mov dx, COM1_BASE ; divisor low byte: 12
mov al, 0Ch
out dx, al
inc dx ; divisor high byte
xor al, al
out dx, al
mov dx, COM1_BASE + 3
mov al, 03h ; clear DLAB, keep 8N1
out dx, al
mov dx, COM1_BASE + 2 ; FCR, PC16550D-specific example
mov al, 0C7h
out dx, al
The code uses port I/O instructions and requires a compiler/assembler and execution environment that permit them. It is not appropriate for a protected environment without I/O permission, or for a system where the serial port is virtualized behind an API. A production driver also needs interrupt masking and vector ownership rules, a receive ring buffer, timeout handling, and a procedure to restore the prior UART state on every exit path.
Test in layers and preserve evidence
Start with a harmless loopback or terminal test. Confirm transmitter-empty and data-ready transitions, then test framing errors by using a deliberately mismatched configuration on a disposable setup. Test FIFO overflow with controlled input only after a recovery path exists. Use a logic analyzer or serial analyzer when timing claims matter. Do not run destructive port tests on a device whose control lines actuate machinery.
In FreeDOS, compare direct programming with the BIOS serial API only on a target where both interfaces are available. BIOS INT 14h may use the same UART but owns a different initialization and timeout model. A program that changes registers directly can invalidate BIOS state, so do not mix the two APIs casually. A device driver is generally the right ownership boundary when several applications need a serial port.
Record target identification, port and IRQ, UART type, clock, divisor, framing, FIFO mode, cable, flow-control policy, test pattern, received count, checksum, error counters, and restoration result. A passing test on one emulator or motherboard is evidence only for that tested configuration. The register map is a shared convention; the actual UART, board wiring, and operating environment determine whether a direct-write sequence is valid.
Related:
- The DOS BIOS Serial API: Programming INT 14h and Its Limits
- How to Build a Reliable FreeDOS Serial Link for Retro Hardware
Sources: