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 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:

On the LC-3 the keyboard and display each get a pair:

AddressRegisterMeaning
\text{xFE00}KBSRkeyboard status — bit[15] set when a new key is waiting
\text{xFE02}KBDRkeyboard data — the ASCII code of the key
\text{xFE04}DSRdisplay status — bit[15] set when the screen is ready for a character
\text{xFE06}DDRdisplay 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.

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.