The DOS BIOS Serial API: Programming INT 14h and Its Limits
Understand the BIOS INT 14h serial API, its register contract, status bits, polling limits, and where a FOSSIL driver or direct UART code becomes necessary.
An application can use a serial port under FreeDOS without speaking directly to UART registers. On IBM PC-compatible systems, BIOS interrupt INT 14h defines a compact set of serial services for initialization, character output, character input, and status. The interface predates FreeDOS and is firmware supplied: the DOS kernel does not implement this BIOS contract. That distinction matters when a DOS program works in one emulator but fails on hardware, or when a driver supplies a richer serial API than the machine BIOS does.
The BIOS calls are useful for small utilities, diagnostics, and basic compatibility. They do not offer a modern asynchronous stream abstraction, a large receive queue, or a portable guarantee about timing. For a hands-on link setup covering wiring, terminal settings, and file-transfer protocols, see the separate serial-link guide. Here the focus is the software interface and the boundary between BIOS services, FOSSIL drivers, and direct UART access.
Identify the BIOS service before calling the interrupt
For the traditional IBM PC BIOS serial interface, AH selects the operation and DX selects the zero-based port number: 0 is normally COM1, 1 COM2, then 2 and 3. The machine may not actually contain each port, and compatible firmware can differ in how it treats unsupported indices. Do not assume that a serial connector is present merely because a conventional COM number exists in software.
The classic function set is:
AH |
Operation | Main input/output |
|---|---|---|
00h |
Initialize the port | AL parameters, DX port; returns line and modem status |
01h |
Transmit one character | AL byte, DX port; returns status in AH |
02h |
Receive one character | DX port; returns status in AH and received byte in AL on success |
03h |
Read port status | DX port; returns line status in AH, modem status in AL |
These are character-at-a-time calls, not block transfers. A program normally initializes once, checks the result, then calls transmit or receive as needed. INT 14h is accessed directly from real-mode application code; launching FreeDOS does not imply that the firmware has implemented every BIOS serial function correctly or that a later virtual machine exposes a legacy UART.
Decode the initialization byte carefully
For the classic BIOS AH=00h operation, AL packs baud rate, parity, stop bits, and data bits. Bits 7 through 5 select one of eight rates from 110 through 9600 bits per second. Bits 4 and 3 encode parity, bit 2 chooses one or two stop bits, and bits 1 and 0 choose five through eight data bits. The encoding is compact but easy to misread because parity uses a two-bit field and the baud rate is a three-bit selector.
For a conventional 9600-baud, no-parity, one-stop-bit, eight-data-bit setting, the fields combine to 1110 0011b, or E3h: baud selector 111, no parity 00, one stop bit 0, and eight data bits 11. This is often written “9600 8N1.” It is an example of the BIOS parameter encoding, not a recommendation that every link should use that rate or framing. Both ends must agree on framing and rate, and the hardware must support it. RBIL notes, for example, that the original PCjr BIOS supports at most 4800 baud, so requesting 9600 there results in 4800.
The following MASM/TASM-style fragment illustrates the register contract for initializing COM1 and transmitting a byte. It assumes real mode and an assembler that accepts hexadecimal suffixes. It is a small call-sequence example, not a complete program with a stack, error messages, or a device-detection routine:
mov dx, 0 ; BIOS port index 0, normally COM1
mov ax, 00E3h ; AH=00h initialize, AL=9600 8N1
int 14h
test ah, 80h ; bit 7 is the timeout/error indication
jnz serial_error
mov dx, 0
mov ah, 01h ; transmit one character
mov al, 'A'
int 14h
test ah, 80h ; transmit reports status in AH
jnz serial_error
Preserve the returned status before making another BIOS call if the program needs to diagnose a failure. A successful call is not proof that a remote peer received or accepted the byte; the BIOS can report local port state, not application-level delivery.
Interpret status as hardware state, not protocol success
AH=03h returns the UART line status in AH and modem-control inputs in AL. The classic line-status bits include timeout, transmit shift register empty, transmit holding register empty, break detected, framing error, parity error, overrun error, and received-data-ready. The modem-status bits include carrier detect, ring indicator, data set ready, and clear to send, plus change indicators. These fields describe low-level serial state. They do not tell the caller whether a remote application parsed a packet, whether a file checksum matched, or whether a flow-control protocol advanced correctly.
For example, “transmit holding register empty” and “transmit shift register empty” are distinct conditions: a byte can have left the holding register while bits are still shifting out. A program that needs a complete protocol transaction must wait for its own acknowledgment, not infer it from one local status bit. Similarly, an overrun means input arrived faster than the consumer handled it at some point; the BIOS return value does not provide an application-owned history of every lost byte.
The receive call AH=02h returns a character in AL when successful and line status in AH. The documented BIOS behavior can wait for a timeout; RBIL also records cases where the read can time out if DSR is not asserted even when a status query reports data ready. Therefore, a loop that assumes AH=02h is a nonblocking “read if available” primitive can hang or incur a substantial delay. Check status first where appropriate, preserve a timeout path, and test modem-control wiring as well as the data pins. A status bit is a snapshot and may change before the next operation.
Know what the BIOS layer does not promise
The traditional BIOS routines are simple and widely recognizable, but they are not equivalent to an interrupt-driven communications stack. RBIL notes that various network and serial drivers support the same BIOS function set while substituting interrupt-driven I/O for the BIOS’s polled approach. That means the same application-facing entry point can be backed by different buffering and scheduling behavior on different systems. Do not make throughput or latency guarantees from the interrupt number alone.
BIOS INT 14h also shares its interrupt vector with other conventions. The FOSSIL standard uses INT 14h but defines a separate service set and register contract. A FOSSIL-aware application must detect and use the FOSSIL interface it targets; it must not assume that every AH function or return value under INT 14h refers to the BIOS API. Conversely, an old BIOS-only utility should not send FOSSIL-specific calls to firmware. This namespace collision is one reason source code and documentation must identify whether a routine expects a BIOS or FOSSIL handler.
If the application needs buffered receive, higher sustained throughput, modem-control policy, or interrupt-driven operation, use a suitable serial driver/API such as a verified FOSSIL implementation or a library built for the target. Direct UART programming is another option for specialized software, but it ties the program to port addresses, UART capabilities, interrupt handling, and hardware configuration. It also has to coexist with any resident serial driver. Never have two independent components reconfigure the same UART without an explicit ownership design.
Test the BIOS path as a complete data path
Start with a controlled setup: record the BIOS or virtual-machine version, the actual UART or emulated device, the port index, cable type, framing, and any modem-control requirements. Use a loopback adapter or a known terminal peer to verify transmit and receive independently. A local loopback test can prove that bytes traverse the local path, but it cannot prove that a remote cable is wired correctly or that a file-transfer protocol is reliable.
For each supported target, measure more than “a character appeared.” Send a known byte pattern at a conservative rate, compare sent and received counts, include bytes that exercise control characters, and calculate a checksum. Repeat at the intended rate under the system’s normal workload. Record timeouts, line-status errors, overrun counts if available, and whether the test used the BIOS function or a driver. If a stream fails only under load, that points toward buffering and service latency, not necessarily incorrect baud encoding.
An acceptance matrix should include: no-port behavior; initialization error handling; transmit success and timeout; receive with and without a peer; modem-status changes when the application depends on them; and longer transfers with an end-to-end checksum. Run the same test in the emulator and on physical equipment when both matter. A virtual BIOS may expose a host-backed socket or serial device with different buffering semantics; a “pass” in one environment is not a claim about the other.
The practical rule is simple: use BIOS INT 14h when a small synchronous character interface is sufficient and the target firmware is part of the tested platform. Choose a driver API when the application needs buffering or predictable throughput. The BIOS is an abstraction boundary, not a guarantee that all serial implementations share the same timing, queueing, or modem behavior.
Related:
- How to Build a Reliable FreeDOS Serial Link for Retro Hardware
- The DOS Packet Driver API: One Interrupt Interface for Many Network Cards
Sources: