Demilade Sonuga's blog

All posts

The Programmable Interrupt Controller I

2023-02-25 · 22 min read

We've finally reached the last player in the interrupts game: The PIC. This component is essential in dealing with events from the timer and the keyboard.

If you need a recap on how the PIC works, check here.

Before we proceed to the main thing, we first need to take a quick look at port IO.

A Few Things About IO

In the x86 architecture, IO is split in two: memory IO and port IO. Memory IO is what occurs when you read something from memory or write something to memory. The stack is in memory, so when you write to a local variable on the stack, you're doing memory IO. Port IO is different in the sense that rather than being used for storing data, it's often used for communicating with peripheral/external devices.

In memory IO, numbers are used to identify memory locations. In port IO, numbers, too, are used to identify ports.

To make this more concrete, consider the following code:

mov eax, 3000
mov (eax), 10

The above is x86 assembly. eax is a 32-bit general-purpose register. The first line copies (the instruction implies movement but it's actually copying) the number 3000 into the register eax. The second line moves the number 10 into the memory address located at the value in eax which in this case is 3000. You can view (register) as a dereference operation: *register.

Essentially, the code just says "copy the number 10 into memory location 3000".

But, if you were to move the number 10 into a port with the number 3000, you have to use the special x86 instruction out.

mov ax, 3000
mov edx, 10
out ax, edx

edx is another general-purpose 32-bit register. ax is a 16-bit general-purpose register. The code effectively moves the number 10 into the port with the number 3000. The 16-bit register was used because port numbers are always 16-bit values.

Likewise, for reading from a memory location:

mov eax, 3000
mov edx, (eax)

The above copies the value at memory address 3000 into the register edx.

But for reading from a port, the in instruction has to be used:

mov ax, 3000
in edx, ax

The code above reads the value from port 3000 into the register edx.

Modeling The PIC

Getting the PIC running the way it ought to be is just a matter of writing values to the right places. It was mentioned in the A Few Things About Interrupts post that the PIC needs to be remapped, that is, the interrupt vector numbers that are sent to the CPU for the hardware interrupts need to be changed to stop them from clashing with exceptions.

To perform this remapping, the PICs must be re-initialized.

So, our task is simple: re-initialize the PICs and map them correctly.

To communicate with the PICs, we use port IO. Each PIC has an 8-bit command port and an 8-bit data port. Each PIC must also have a base offset associated with it. It's this base offset that is used to determine the interrupt vector that the interrupt request will be translated to. For example, if the first PIC has a base offset of 80, then an interrupt request on line 3 will be translated to interrupt vector number 80 + 3 == 83. And the CPU will end up heading to entry 83 in the IDT.

The index of the first free entry in the IDT is 32, so it's an okay choice for us to use as the first PIC's offset. The first PIC has 8 lines, so entries from 32 to 39 in the IDT will be used for it. The second PIC's offset can then be 32 + 8 == 40 since that's where the next set of free entries starts from.

To translate this to code, create a new pics.rs file and throw this in:

// A Programmable Interrupt Controller
struct PIC {
// The base index in the IDT that the PIC's interrupts are mapped to
offset: u8,
// PIC's command port
command: ?,
// PIC's data port
data: ?
}

// The chained PICs that map hardware interrupts to interrupt
// vector numbers for the CPU
pub struct PICs {
first: PIC,
second: PIC
}

impl PICs {
pub fn new() -> Pics {
let first = Pic {
offset: 32,
command: new port,
data: new port
};
let second = Pic {
offset: 32 + 8,
command: new port,
data: new port
};
Pics {
first,
second
}
}
}

Before we proceed, we need to first model ports. Ports, like memory locations, can be read and written. Create a new file port.rs.

use core::arch::asm;

// An IO port
pub struct Port(u16);

The sole u16 field will serve as the port address.

For creating a new Port instance:

impl Port {
// Create a new port
pub fn new(port_no: u16) -> Self {
Self(port_no)
}
}

For reading:

impl Port {
// ...Others

// Read from the port
pub fn read(&self) -> u8 {
let value: u8;
unsafe {
asm!("in al, dx", out("al") value, in("dx") self.0);
}
value
}
}

al is a general-purpose 8-bit register. The in("dx") self.0 tells the compiler to generate code that puts the port number into the 16-bit register dx before the instruction in al, dx. Similarly, the out("al") value tells the compiler to generate code that puts the value of al into the variable value after executing in al, dx. The effect of this is to read the 8-bit value from port self.0.

For writing:

impl Port {
// ...Others

// Write to the port
pub fn write(&mut self, value: u8) {
unsafe {
asm!("out dx, al", in("dx") self.0, in("al") value);
}
}
}

The operation specified here is similar to reading. The argument value is placed into register al and the port number is placed into register dx before the instruction out dx, al is executed, resulting in value being written to port self.0.

To make port.rs a module, in main.rs:

// ...Others

#![feature(panic_info_message)]

pub mod port; // NEW
mod font;
use font::FONT;

// ...Others

The port numbers of the first PIC's command and data ports are 0x20 and 0x21 respectively, while those of the second PIC are 0xa0 and 0xa1, respectively.

In pics.rs:

use crate::port::Port;

struct PIC {
offset: u8,
// DELETED: command: ?,
command: Port, // NEW
// DELETED: data: ?
data: Port // NEW
}

// The chained PICs that map hardware interrupts to interrupt
// vector numbers for the CPU
pub struct PICs {
first: PIC,
second: PIC
}

impl PICs {
pub fn new() -> Pics {
let first = Pic {
offset: 32,
// DELETED: command: new port,
command: Port::new(0x20), // NEW
// DELETED: data: new port
data: Port::new(0x21) // NEW
};
let second = Pic {
offset: 32 + 8,
// DELETED: command: new port,
command: Port::new(0xa0), // NEW
// DELETED: data: new port
data: Port::new(0xa1) // NEW
};
Pics {
first,
second
}
}
}

To perform the actual remapping to make the offsets we've associated with the PICs valid, we need to re-initialize. To do this, create a new function:

impl PICs {
// ...Others

// Set up the PICs
pub fn init(&mut self) {

}
}

Before the PICs can receive any command, they must first receive the initialization command which is the number 0x11. After giving the two PICs this command, we can give them information about how they should be initialized in the following order:

  1. The vector offset.
  2. How the PICs are connected (which is first and which is second).
  3. Environment info.

All the commands are just 8-bit values we're writing to the command ports.

Now, we proceed by first giving the PICs the initialization command:

use crate::port::Port;

const CMD_INIT: u8 = 0x11; // NEW

// ...Others

impl PICs {
// ...Others

pub fn init(&mut self) {
// Start initialization sequence
self.first.command.write(CMD_INIT);
self.second.command.write(CMD_INIT);
}
}

After each command is written, we have to wait to give the PIC time to process commands in case they're not processed immediately.

To perform this wait, we write to a garbage port after each of the commands is sent:

impl PICs {
// ...Others

pub fn init(&mut self) {
// NEW:
// Garbage port for waiting
let mut wait_port = Port::new(0x80);
let mut wait = || wait_port.write(0);

self.first.command.write(CMD_INIT);
wait(); // NEW
self.second.command.write(CMD_INIT);
wait(); // NEW
}
}

After sending the initialization commands, we can now set the vector offsets.

impl PICs {
// ...Others

pub fn init(&mut self) {
// ...Others
self.second.command.write(CMD_INIT);
wait();

// NEW:
// Setup base offsets
self.first.data.write(self.first.offset);
wait();
self.second.data.write(self.second.offset);
wait();
}
}

Next in the initialization is telling the PICs how they're connected. Again, this is just a matter of sending special numbers that have meaning to the PICs.

impl PICs {
// ...Others

pub fn init(&mut self) {
// ...Others
self.second.data.write(self.second.offset);
wait();

// Tell first that there is a second PIC at IRQ 2
self.first.data.write(4);
wait();
// Tell the second PIC it's connected to the first's line 2
self.second.data.write(2);
wait();
}
}

Lastly, we need to tell the PICs about their environment.

use crate::port::Port;

const CMD_INIT: u8 = 0x11;
const MODE_8086: u8 = 0x01; // NEW

impl PICs {
// ...Others

pub fn init(&mut self) {
// ...Others
self.second.data.write(2);
wait();

// Telling the PICs about the environment they're in
self.first.data.write(MODE_8086);
wait();
self.second.data.write(MODE_8086);
wait();
}
}

Take Away

  • Two types of IO in x86: Port IO and memory IO.
  • Special in and out instructions are used to read from and write to ports.
  • Ports are regularly used to communicate with devices.

For the full code, go to the repo

In The Next Post

We'll finish up the PICs and get started with the timer.

References