Memory-Mapped I/O and Polling
A processor that can only add numbers in its own registers is a very expensive paperweight. To be useful it
must talk to the outside world — read a keyboard, paint a screen, poke a network card. But
the instruction
cycle we built knows how to do exactly two things with the world beyond the CPU: read a
memory address and write a memory address. So here is the elegant trick that lets that tiny
vocabulary control any device ever made: make the devices look like memory.
This is memory-mapped I/O. Each device exposes a couple of registers, and those registers
are wired to appear at special addresses in the memory map. Loading from that address reads the
device; storing to it commands the device. We use the LC-3's keyboard and display — KBSR/KBDR
and DSR/DDR — and meet the simplest way to use them: polling, the humble,
wasteful busy-wait loop.
Two ways to reach a device
There are two schools for connecting device registers to a CPU:
- Memory-mapped I/O — device registers live in the ordinary address space. No new
instructions: the same \text{LD}/\text{ST} that touch
RAM also touch devices. The LC-3, ARM and RISC-V all do this.
- Port-mapped (isolated) I/O — devices live in a separate I/O address space
reached by special instructions. x86 has this: dedicated \text{IN} and
\text{OUT} instructions and a distinct set of 65,536 "port" addresses, kept
apart from the memory the same program uses.
Memory-mapped I/O has largely won because it keeps the ISA small — every addressing mode, every load/store
optimisation, works on devices for free. Its cost is that it spends address space: a chunk of the
map is reserved for devices and can't hold RAM. On a machine with a huge 64-bit address space, that is a
trade worth making a thousand times over.
Status and data: two registers per device
A device typically exposes (at least) two registers, and the pattern is worth memorising because it repeats
everywhere:
- a status register with a ready bit — "do I have a character for you?"
(input) or "am I free to accept one?" (output);
- a data register — the actual byte going in or out.
On the LC-3 the keyboard and display each get a pair:
| Address | Register | Meaning |
| \text{xFE00} | KBSR | keyboard status — bit[15] set when a new key is waiting |
| \text{xFE02} | KBDR | keyboard data — the ASCII code of the key |
| \text{xFE04} | DSR | display status — bit[15] set when the screen is ready for a character |
| \text{xFE06} | DDR | display data — write a character here to print it |
The memory map: where the devices hide
Here is the LC-3 address space as a tower, low addresses at the top. Almost all of it is ordinary memory —
vectors, the OS, and your program. The thin slice at the very bottom,
\text{xFE00–xFFFF}, is not memory at all: those addresses are wired
straight to device registers. The zoom shows the four keyboard/display registers living there.
This is the whole magic of memory-mapped I/O in one picture. A \text{LD} from
\text{xFE00} doesn't read RAM — it reads the keyboard's status bit. A
\text{ST} to \text{xFE06} doesn't write RAM — it makes
a letter appear on screen. Same instructions, different corner of the map.
Polling: the busy-wait loop
Devices are glacially slow compared to a CPU — a person types a few keys a second while the processor runs
billions of cycles. So the CPU cannot just read \text{KBDR} whenever it likes;
it must first check the ready bit. Polling is the simplest strategy: spin in a loop
reading the status register until the ready bit flips, then transfer the data.
In LC-3 assembly, waiting for a keypress is the classic three-line spin:
POLL LDI R0, KBSRptr ; R0 <- KBSR (the status word)
BRzp POLL ; bit[15] still 0? keep spinning
LDI R0, KBDRptr ; ready! read the character from KBDR
Output is the mirror image: spin on \text{DSR} until the display says "I'm ready",
then store the character into \text{DDR}. The shape — wait for ready, then
transfer — is identical for every polled device on Earth.
- memory-mapped I/O: device registers appear as ordinary memory addresses, so plain
load/store instructions drive the hardware;
- each device has a status register (a ready/busy bit) and a data
register;
- polling = busy-wait: loop reading the status register until ready, then read/write the
data register;
- polling is simple but burns every cycle spent spinning — the CPU does no useful work while it waits.
Simulate it: count the wasted spins
Let's poll a keyboard that delivers the message one character at a time, with the hardware taking several
spin-iterations to produce each key. The loop reads the status register, busy-waits while the ready bit is
clear, and consumes a character when it sets. Watch how many polls are pure waste.
// Polling a memory-mapped keyboard. KBSR.ready is the status bit; KBDR is the data register.
const message = "HI!";
const KBSR = { ready: false };
let KBDR = 0;
let charIdx = 0;
let spinsUntilNext = 4; // slow hardware: 4 CPU polls between characters
let totalPolls = 0, ch2read = 0;
const out: string[] = [];
// The device's own clock: after enough polls, it makes the next character "ready".
function deviceTick(): void {
if (!KBSR.ready && charIdx < message.length) {
spinsUntilNext -= 1;
if (spinsUntilNext <= 0) { KBDR = message.charCodeAt(charIdx); charIdx += 1; KBSR.ready = true; }
}
}
while (ch2read < message.length) {
totalPolls += 1; // one trip round the poll loop = one status read
deviceTick();
if (KBSR.ready) { // ready bit set -> transfer the data
out.push(String.fromCharCode(KBDR));
ch2read += 1;
KBSR.ready = false; // clearing it lets the device fetch the next key
spinsUntilNext = 4;
}
// else: busy-wait -- this poll did no useful work
}
const wasted = totalPolls - ch2read;
console.log(`Read "${out.join("")}" with ${totalPolls} status polls for ${ch2read} characters.`);
console.log(`Busy-wait polls wasted: ${wasted} (${Math.round(100 * wasted / totalPolls)}% of the loop).`);
Most of the loop's trips did nothing but ask "ready yet?" and hear "no". Scale the device's slowness up to
real human typing speed and the CPU would spin billions of times per keystroke — a spectacular
misuse of a machine, and precisely the waste Amdahl's law warns about: the whole runtime becomes the part you
can't speed up. That is the motive for the next lesson's fix, the
interrupt.
Because a device register is not ordinary memory, and a compiler doesn't know that. Look at the poll
loop: it reads \text{KBSR} over and over, testing the same address. A clever
optimiser, seeing nothing in the loop change that address, will "helpfully" read it once and cache
the value in a register — turning your poll into an infinite loop that never notices the ready bit. The
volatile keyword tells the compiler "this location can change under your feet
and reads/writes have side effects — touch memory every time, in exactly the order I wrote."
Memory-mapped I/O is where volatile stops being pedantry and starts being the difference between
a working driver and a hang.
With RAM, reading a cell twice gives the same value and changes nothing. Device registers break that
assumption. On the LC-3, reading \text{KBDR} clears the
keyboard's ready bit — that read is how the hardware learns the character was consumed and it may fetch the
next one. So a stray extra read can swallow a keystroke, and reading a status register may itself reset flags.
Treat device registers as live wires with side effects, not as a scratchpad: read each exactly when the
protocol says, no more, no less.