Part IV · Chapter 11

I/O Ports and Memory-Mapped I/O

A processor that can only compute and remember is a brain in a jar. To be useful it has to read keys, show pictures, store files and talk to the network. In this chapter we see how it reaches the outside world, and discover that it does so with the very same tools it already has: reading and writing numbers.

A computer is connected to an astonishing variety of things: keyboards and mice, screens, disks, network cards, speakers, cameras, game controllers. Each works in its own way. Yet the processor we built in Part II knows nothing about any of them. All it can do is move numbers between its registers and addresses on the bus.

Amazingly, that turns out to be enough. The trick is to make every device look like a few numbers the processor can read and write. The name for this whole business is input/output, or I/O.

Devices are registers too

Every device is run by a small circuit called its controller. The keyboard has one, the disk has one, the network card has one. Each controller does the hard, device-specific work itself, and offers the processor a handful of device registers, usually of three kinds:

Think of a counter at a post office: a tray for incoming letters, a tray for outgoing ones, and a little light that says whether the clerk is busy. You never need to know how the post office works inside. Talking to a device is just reading and writing its registers.

Two ways in

The processor still needs a way to reach those registers. Over the years, two approaches have won.

Port-mapped I/O. x86 gives devices an address space of their own, completely separate from memory: 65,536 I/O ports, numbered 0 to 0xFFFF. Two special instructions use it: in reads a port and out writes one. The bus tells everyone which kind of access it is: the IBM PC's bus has separate wires for "memory read", "memory write", "I/O read" and "I/O write". So RAM simply ignores port accesses, and port devices ignore memory ones.

Memory-mapped I/O. The other approach is to give device registers ordinary memory addresses. The address decoder from Chapter 8 routes each address to the chip that owns it, and some addresses simply belong to devices instead of RAM. Ordinary loads and stores then talk to the device. You've already seen one: the IBM PC's video memory at 0xB8000. Store a byte there and it appears on the screen.

x86 uses both. RISC-V, like ARM and most modern designs, has only memory-mapped I/O: there are no in or out instructions at all.

Click each instruction and watch which device answers. Then switch to RISC-V. Its memory map is that of the "virt" machine of QEMU, a free emulator often used to learn RISC-V.

Reading the keyboard

On the IBM PC, the keyboard controller sits at port 0x60. Every time a key goes down, the keyboard sends a byte, its scan code, and the program reads it with one instruction:

in      al, 0x60        ; read the latest scan code into AL

A scan code doesn't say "the letter A". It says "the key just to the right of Caps Lock". It names a position, not a character. Turning positions into letters is the operating system's job, which is why the same keyboard can type in English, French or Greek just by changing a setting.

The keyboard also sends a byte when a key comes back up. The "make" code (key down) and "break" code (key up) differ in just one bit: bit 7. That's how a game can tell that you're holding the arrow key rather than tapping it.

These are the bytes a PC keyboard puts in port 0x60 (in "scan code set 1", the original PC's codes). Press and release a few keys, hold one down for a second, and try the arrow keys. On a touch screen, press and hold the on-screen keys.

On the original 1981 keyboard, the arrows shared keys with the number pad. The separate block of arrow keys arrived with IBM's bigger keyboard in 1986. To stay compatible, those new keys send the same codes as the old number-pad arrows, but with an extra E0 byte first, meaning "the next code is one of the new keys".

Sending a byte: the serial port

Now let's go the other way and send data out. One of the oldest and simplest devices is the serial port, which sends bytes down a wire one bit at a time. Its controller is called a UART (universal asynchronous receiver-transmitter). The PC's first serial port, COM1, has its registers at ports 0x3F8 to 0x3FF. Two of them matter here:

Here's the catch. The serial port is slow. At its usual top speed, 115,200 bits per second, each byte (8 data bits plus a start and a stop bit) takes about 87 microseconds to send. A 3 GHz processor runs about 260,000 clock ticks in that time. What happens if the program writes bytes faster than the port can send them?

Run each program. To keep the demo watchable the speed gap is shrunk to 30 to 1; in a real computer it's more like 260,000 to 1. In the data bits, notice that a UART sends the lowest bit first.

Polling

The first program loses most of the message: the data register holds just one byte, and each new write replaces the previous one before it can be sent. The second program checks the status register first, again and again, until the device says it's ready. That's called polling, or busy-waiting, and it works. Here it is in real code:

; x86-64: send the letter H through COM1
        mov     dx, 0x3FD       ; port of the line status register
wait:   in      al, dx          ; read the status
        test    al, 0x20        ; is bit 5, "ready", set?
        jz      wait            ; no: ask again
        mov     dx, 0x3F8       ; port of the data register
        mov     al, 'H'
        out     dx, al          ; send it

On RISC-V there are no ports, so the same UART design is simply placed at a memory address: 0x10000000 on the QEMU "virt" machine. The registers are in the same order as on the PC, so the status register is 5 bytes further on, and plain loads and stores do the job:

# RISC-V: the same thing, with memory-mapped I/O
        li      a0, 0x10000000  # address of the UART
wait:   lbu     t0, 5(a0)       # read the status register
        andi    t0, t0, 0x20    # keep only bit 5, "ready"
        beqz    t0, wait        # not ready: ask again
        li      t1, 'H'
        sb      t1, 0(a0)       # send it

Notice the x86 version needs dx: an in or out instruction can only hold a port number up to 255 inside itself, and 0x3F8 is bigger, so it has to go in a register.

Polling is simple, and fine when the processor has nothing better to do. But look at the cost: for every byte sent, a modern processor might ask "ready yet?" tens of thousands of times. A computer running a dozen programs can't afford to spend its time staring at a slow device. We need a way for the device to tap the processor on the shoulder when it's ready. That's the subject of the next chapter.

Device registers are not memory

With memory-mapped I/O, device registers look just like memory. But they don't behave like memory, and that catches people out:

Programmers face the same problem one level up. A compiler (Chapter 23) assumes memory doesn't change by itself, and might read the status just once and loop forever. In C, the keyword volatile tells it: "every single read and write here really matters".

// C: the RISC-V polling loop
volatile unsigned char *uart = (unsigned char *)0x10000000;
while ((uart[5] & 0x20) == 0) { }   // wait until ready
uart[0] = 'H';                      // send

Finally, ordinary programs aren't allowed to touch devices at all. Only the operating system can: x86 refuses in and out from normal programs, and on any processor, device addresses are simply missing from their page tables. A program that wants to print asks the operating system to do it (Chapter 20).

Meanwhile, inside a real CPU

The x86 port instructions are among the shortest in the whole instruction set:

e4 60           in      al, 0x60        ; 2 bytes: opcode + port number
e6 43           out     0x43, al        ; 2 bytes
ec              in      al, dx          ; 1 byte: the port is in DX
ee              out     dx, al          ; 1 byte

On Linux, the file /proc/ioports lists which driver owns which ports. Here are some of its lines on the computer this book was written on. Linux hides the port numbers from ordinary users, so we've filled in the standard ones it uses on every PC:

$ cat /proc/ioports
0000-001f : dma1               ← the original PC's DMA chip (Chapter 13)
0020-0021 : pic1               ← interrupt controller (Chapter 12)
0040-0043 : timer0             ← the timer chip
0050-0053 : timer1
0060-0060 : keyboard           ← our port 0x60
0064-0064 : keyboard
0080-008f : dma page reg
00a0-00a1 : pic2
00c0-00df : dma2
00f0-00ff : fpu

Over 40 years later, a modern PC still answers at the same port numbers as the 1981 original. Most of these chips no longer exist as separate parts: the chipset imitates them so that old software keeps working.

Try it yourself

  1. Pressing the A key sends the scan code 0x1E. What byte arrives when you let go?
    Show answerThe same code with bit 7 set: 0x1E + 0x80 = 0x9E.
  2. Why does the x86 polling loop write mov dx, 0x3F8 and out dx, al instead of just out 0x3F8, al?
    Show answerThe port number inside an in or out instruction is a single byte, so it can only be 0 to 255. Port 0x3F8 (1016) is too big, so it has to come from the DX register, which can hold any port from 0 to 65,535.
  3. A serial port sends 115,200 bits per second, and each byte takes 10 bits. How many bytes per second is that? How many clock ticks of a 3 GHz processor pass while one byte is sent?
    Show answer115,200 / 10 = 11,520 bytes per second, so one byte takes 1/11,520 s, about 86.8 µs. At 3 billion ticks per second, that's about 260,000 ticks.
  4. On RISC-V, how can the processor tell whether a load is reading RAM or a device?
    Show answerIt can't, and it doesn't need to. It's the same load instruction either way. Only the address differs, and the address decoder outside the processor routes it to RAM or to the device. (The hardware does know which ranges are devices, so it never caches them.)
  5. In the "just write" program, only HLD arrives. Why those three letters?
    Show answerThe H moves straight from the empty data register into the port's sending circuit. While it travels down the wire, the CPU overwrites the data register again and again. When the wire becomes free, the port takes whatever is waiting at that moment: the L of WORLD. Finally the CPU writes the D, and since the program then ends, nothing overwrites it, so it's sent too.

What we learned

Polling is like a child in the back seat asking "are we there yet?" every few seconds. In the next chapter we'll replace it with something far better: interrupts, which let a device stop the processor exactly when it needs attention, so the rest of the time the processor can get on with real work.