Introduction: The Invisible Conversations Between CPU and Hardware
Let me start with a confession: when I first learned assembly language, I/O programming felt like black magic. You write IN AL, 0x60 and suddenly you’re reading keyboard data. You write OUT 0x3F8, AL and somehow characters appear on a serial terminal. How does this actually work? Where does the data go? Why do we use ports instead of regular memory addresses?
After twenty years of writing low-level code—everything from bootloaders to device drivers to reverse-engineering malware—I’ve developed a deep appreciation for I/O programming. It’s the foundation of everything your computer does. Without I/O, your CPU would be trapped in its own little world, unable to communicate with keyboards, displays, disks, or networks. Every keystroke you type, every pixel that appears on your screen, every packet that travels across the internet—it all starts with I/O instructions.
In this article, we’re going to dive deep into the world of I/O programming in assembly language. We’ll explore the two fundamental approaches that computer architectures use to handle I/O: memory-mapped I/O and port-mapped I/O. We’ll understand why x86 processors are unusual in supporting both. We’ll master the IN and OUT instructions that let us talk to hardware. We’ll explore the 64K I/O address space and learn which ports correspond to which devices. We’ll understand the three fundamental approaches to moving data: programmed I/O, interrupt-driven I/O, and DMA. And we’ll write real, working code that talks to the keyboard controller, reads the real-time clock, and sends data over a serial port.
By the time you finish this article, you won’t just know the syntax of I/O instructions—you’ll understand the philosophical and architectural decisions that shape how computers communicate with the outside world. You’ll think like a systems programmer.
Chapter 1: I/O in Assembly – Memory-Mapped I/O vs Port-Mapped I/O (x86 Has Both!)
Here’s something that fascinates me about computer architecture: there’s no universal agreement on how the CPU should communicate with hardware devices. Two major philosophical camps have emerged over the decades, and understanding both is crucial for any serious assembly programmer.
Memory-Mapped I/O: Devices Live in Memory Space
The first approach is memory-mapped I/O (MMIO). The concept is elegantly simple: hardware devices are mapped into the same address space as regular memory. When you write to a specific memory address, you’re not actually writing to RAM—you’re writing to a device register. When you read from a specific address, you’re reading from a device.
Let me give you a concrete example. Suppose you’re programming an ARM-based microcontroller (like the popular STM32 series). The UART (serial port) might have its control registers mapped to memory addresses like 0x40001000. To send a character, you’d write something like this in assembly:
; ARM assembly example - memory-mapped I/O
LDR R0, =0x40001000 ; Load UART data register address
MOV R1, #'A' ; Character to send
STR R1, [R0] ; Write to UART data register
Notice what just happened there? That STR R1, [R0] instruction looks exactly like a normal memory store. And that’s the whole point of memory-mapped I/O—from the CPU’s perspective, talking to devices uses the exact same instructions as talking to memory. No special IN or OUT instructions needed.
The advantages of memory-mapped I/O are compelling:
- Unified address space: You don’t need special instructions. Every load and store can potentially access a device.
- Compiler-friendly: C code can use pointers to access device registers (
*(volatile uint32_t *)0x40001000 = 'A';), making driver development much easier. - Flexibility: You can use any memory access pattern—byte, halfword, word, even SIMD loads/stores if the device supports it.
- No artificial limits: The device address space is only limited by the CPU’s address bus width.
Most modern architectures use memory-mapped I/O extensively: ARM, RISC-V, MIPS, PowerPC, and others. Even x86 uses MMIO for many modern devices (PCIe devices are primarily accessed via memory-mapped registers).
Port-Mapped I/O: A Separate I/O Space
The second approach is port-mapped I/O (also called I/O-mapped I/O or simply “port I/O”). This approach creates a completely separate address space specifically for I/O devices. The CPU has dedicated pins and signaling for I/O space, separate from memory space.
Here’s the key insight: with port-mapped I/O, the instruction MOV AX, [0x1234] and IN AX, 0x1234 access completely different things. The first reads from memory address 0x1234; the second reads from I/O port 0x1234. They’re in separate universes.
x86 processors are unusual—some would say “burdened by history”—in supporting both memory-mapped I/O AND port-mapped I/O. This is because the original 8086 processor (1978) introduced port-mapped I/O with the IN and OUT instructions, and Intel has maintained backward compatibility for over 40 years.
Here’s what port-mapped I/O looks like in x86 assembly:
; x86 assembly example - port-mapped I/O
mov dx, 0x3F8 ; COM1 serial port data register
mov al, 'A' ; Character to send
out dx, al ; Send character to serial port
The OUT instruction sends the value in AL (or AX/EAX for 16/32-bit transfers) to the I/O port specified by DX (or an immediate 8-bit port number).
The characteristics of port-mapped I/O include:
- Separate address space: I/O ports don’t conflict with memory addresses
- Special instructions required: You must use
INandOUT(and their variants) - Limited address space: x86 originally had 16-bit port addresses (64K ports), though most devices use ports below 0x400
- Faster for small transfers: Port I/O has less overhead than setting up memory-mapped registers
Why x86 Has Both (And Why That Matters)
Now, here’s where it gets interesting. The original 8080 and 8085 processors (Intel’s 8-bit CPUs from the mid-1970s) used port-mapped I/O exclusively. When Intel designed the 8086, they wanted to maintain backward compatibility with 8080 software. So they kept port-mapped I/O and added the IN and OUT instructions.
But the 8086 also supported memory-mapped I/O! The architects realized that MMIO was more flexible for complex devices. So they gave x86 processors a unique dual-personality: you can use either approach.
Over the decades, this has led to some interesting patterns:
- Traditional PC devices (keyboard controller, real-time clock, serial ports, parallel ports) use port-mapped I/O at well-known port addresses
- Modern devices (PCIe devices, USB controllers, graphics cards) primarily use memory-mapped I/O
- The BIOS and early boot code heavily uses port I/O because it’s simple and works even before memory controllers are initialized
As an assembly programmer, you need to know both. When you’re writing a bootloader or BIOS extension, you’ll use port I/O. When you’re writing a modern device driver for Linux or Windows, you’ll primarily use memory-mapped I/O. But understanding both gives you the complete picture of how I/O works.
The Great I/O Space Competition: A Historical Perspective
Let me share a historical anecdote that illustrates why this matters. In the 1980s, when the IBM PC was being designed, the engineers had to decide which devices would use which I/O approach. They assigned specific port addresses to standard devices:
- 0x0000-0x001F: DMA controller 1 (8237)
- 0x0020-0x0021: Programmable Interrupt Controller 1 (8259)
- 0x0040-0x0043: Programmable Interval Timer (8253/8254)
- 0x0060-0x006F: Keyboard controller (8042)
- 0x0070-0x0071: Real-time clock (RTC) and CMOS
- 0x0080-0x008F: DMA page registers
- 0x00A0-0x00A1: Programmable Interrupt Controller 2 (8259)
- 0x00C0-0x00DF: DMA controller 2 (8237)
- 0x02F8-0x02FF: COM2 serial port
- 0x03F8-0x03FF: COM1 serial port
These addresses became sacred. Even modern computers reserve these port addresses for backward compatibility, even if the actual hardware is emulated by the chipset or virtualized by the BIOS.
When you write IN AL, 0x60 to read the keyboard, you’re participating in a tradition that goes back to 1981. That’s pretty cool when you think about it.



