Programmable Interrupt Controller

Every interrupt we have now comes from the CPU itself, these are self-contained exceptions. But what about when some other piece of hardware needs to send an interrupt? Well this is where a piece of hardware called the Programmable Interrupt Controller (PIC) comes in.

The PIC handles Interrupt Requests (IRQs), which are hardware interrupts. There are 16 IRQ lines in total. For example, IRQ0 is normally connected to the timer and IRQ is normally connected to the keyboard. Annoyingly, due to the way the original IBM PC architecture mapped hardware and IRQs, the IRQs overlap with CPU exception vectors we are already using. For example, IRQ0 originally uses interrupt vector 8, which conflicts with the CPU's double-fault exception. We need to remap the PIC so that IRQ0 starts at interrupt vector 32 instead to circumvent these conflicts.

In our architecture, the 16 IRQ lines are split between two PICs: a master and a slave, with each PIC handling eight IRQ lines. The two PICs are physically connected. With the slave's interrupt output connected to the master's IRQ2 input. This means the master uses IRQ2 to receive interrupts from the slave.

To communicate with the PIC(s), we must first get their IO base addresses, here are some definitions for that:

//PORT DEFINITIONS

#define PIC1        0x20        /* IO base address for master PIC */
#define PIC2        0xA0        /* IO base address for slave PIC */
#define PIC1_COMMAND    PIC1
#define PIC1_DATA   (PIC1+1)
#define PIC2_COMMAND    PIC2
#define PIC2_DATA   (PIC2+1)

We communicate to the PIC (when initializing) using Initialization Command Words (ICWs) There's 4: 1 handles the start of initialization, 2 handles where the interrupt vector begins, 3 handles how the master and slave are connected, and 4 handles the mode. Here are some ICW codes that I have defined:

//ICW DEFINITIONS

#define ICW1_ICW4 0x01
#define ICW1_SINGLE 0x02
#define ICW1_INTERVAL4 0x04
#define ICW1_LEVEL 0x08
#define ICW1_INIT 0x10

#define ICW4_8086 0x01
#define ICW4_AUTO 0x02
#define ICW4_BUF_SLAVE 0x08
#define ICW4_BUF_MASTER 0x0C
#define ICW4_SFNM 0x10

These constants represent the bit flags used when initializing the PICs. We will use ICW1_ICW4 to show that an ICW4 will follow during initialization, and ICW1_INIT to place the PICs into initialization mode. Finally, ICW4_8086 selects the 8086-compatible interrupt mode that we want to use.

Let's have a look at our function definitions for Setting up the PIC, setting up IRQs and handling IRQs:

void irq_handler(registers_t* regs);

void pic_remap(int offset1, int offset2);

extern void outb(uint16_t port, uint8_t value);
extern uint8_t inb(uint16_t port);
extern void io_wait(void);

void pic_send_eoi(uint8_t irq);

extern void irq0(void);
extern void irq1(void);
extern void irq2(void);
extern void irq3(void);
extern void irq4(void);
extern void irq5(void);
extern void irq6(void);
extern void irq7(void);
extern void irq8(void);
extern void irq9(void);
extern void irq10(void);
extern void irq11(void);
extern void irq12(void);
extern void irq13(void);
extern void irq14(void);
extern void irq15(void);

Here we have 3 helper functions outb simply sends a byte to I/O, inb receives a byte from I/O, io_wait is a simple way of introducing a small delay between I/O operations. (real timeouts will come when we make our timer driver, but what we have here isn't bad for the purpose).

Now that we know the helper functions, let's have a look at the pic_remap function first. This should be the first step in our logic of handling the PIC. The function looks like this:

void pic_remap(int offset1, int offset2) {
    //save state of enabled IRQs
    uint8_t a1 = inb(PIC1_DATA);
    uint8_t a2 = inb(PIC2_DATA);

    /* Enter initialization mode. */
    outb(PIC1_COMMAND, ICW1_INIT | ICW1_ICW4);
    io_wait();

    outb(PIC2_COMMAND, ICW1_INIT | ICW1_ICW4);
    io_wait();

    /* set up first interrupt vector used by master and slave */

    outb(PIC1_DATA, offset1);
    io_wait();

    outb(PIC2_DATA, offset2);
    io_wait();

    /* Connect master and slave through IRQ2 line */
    outb(PIC1_DATA, 4);
    io_wait();

    outb(PIC2_DATA, 2);
    io_wait();

    /* select 8086/x86 interrupt mode */
    outb(PIC1_DATA, ICW4_8086);
    io_wait();

    outb(PIC2_DATA, ICW4_8086);
    io_wait();

    /* restore interrupt masks */
    outb(PIC1_DATA, a1);
    outb(PIC2_DATA, a2);

}

You can see in this code that when we use ICWs, we need to send the commands both to the master and slave PICs. The first thing we do is use the OR operator to say that we are initializing and performing an ICW4 after this. For ICW2, we tell each PIC which interrupt-vector range it should use. The master starts at 0x20 (32) and the slave starts at 0x28 (40), so the master handles vectors 32-39 and the slave handles vectors 40-47. Next we tell the master that the slave is connected to its IRQ line by sending 4 to the master's data port. After this, we tell the slave that it is connected through the master's IRQ2 line by sending 2 to the slave's port. We then select x86 mode and then restore the masks.

Now let's look at our helper functions:

outb:
    mov dx, [esp + 4]
    mov al, [esp + 8]
    out dx, al
    ret
inb:
    mov dx, [esp + 4]
    in al, dx
    movzx eax, al
    ret
io_wait:
    mov al, 0
    out 0x80, al
    ret

Simple, remember to add global statements so they are visible to C.

That's PIC remapping all set up now we can look at setting it up with the IDT. This is similar to what we did before with the ISRs. First let's set them up when initializing the IDT.

    idt_set_gate(32, (uint32_t)irq0,  0x08, 0x8E);
    idt_set_gate(33, (uint32_t)irq1,  0x08, 0x8E);
    idt_set_gate(34, (uint32_t)irq2,  0x08, 0x8E);
    idt_set_gate(35, (uint32_t)irq3,  0x08, 0x8E);
    idt_set_gate(36, (uint32_t)irq4,  0x08, 0x8E);
    idt_set_gate(37, (uint32_t)irq5,  0x08, 0x8E);
    idt_set_gate(38, (uint32_t)irq6,  0x08, 0x8E);
    idt_set_gate(39, (uint32_t)irq7,  0x08, 0x8E);
    idt_set_gate(40, (uint32_t)irq8,  0x08, 0x8E);
    idt_set_gate(41, (uint32_t)irq9,  0x08, 0x8E);
    idt_set_gate(42, (uint32_t)irq10, 0x08, 0x8E);
    idt_set_gate(43, (uint32_t)irq11, 0x08, 0x8E);
    idt_set_gate(44, (uint32_t)irq12, 0x08, 0x8E);
    idt_set_gate(45, (uint32_t)irq13, 0x08, 0x8E);
    idt_set_gate(46, (uint32_t)irq14, 0x08, 0x8E);
    idt_set_gate(47, (uint32_t)irq15, 0x08, 0x8E);

Then we can write a macro that handles them:

global irq0
global irq1
global irq2
global irq3
global irq4
global irq5
global irq6
global irq7
global irq8
global irq9
global irq10
global irq11
global irq12
global irq13
global irq14
global irq15

%macro IRQ 2
irq%1:
    push dword 0
    push dword %2
    jmp irq_common_stub
%endmacro

IRQ 0, 32
IRQ 1, 33
IRQ 2, 34
IRQ 3, 35
IRQ 4, 36
IRQ 5, 37
IRQ 6, 38
IRQ 7, 39
IRQ 8, 40
IRQ 9, 41
IRQ 10, 42
IRQ 11, 43
IRQ 12, 44
IRQ 13, 45
IRQ 14, 46
IRQ 15, 47

Unlike our CPU exceptions, hardware IRQs do not automatically push an error code. We push 0 ourselves so that the stack layout matches the structure expected by our common handler. Then we push the interrupt vector number. Then we have our stub which is the same other as the function we call:

irq_common_stub:
    pusha

    mov ax, ds
    push eax

    push esp
    call irq_handler
    add esp, 4

    pop eax

    popa

    add esp, 8
    iret

Now let's look at the basic handler, here it is:

void irq_handler(registers_t* regs) {
    vga_text_writeline(&terminal, "IRQ");
    pic_send_eoi(regs->interrupt_number - 32);
}

The text is there for later testing. EOI stands for End Of Interrupt. It's a command sent to the PIC to tell it that we have finished handling the interrupt. The PIC then cleans up for us rather than us manually having to return or something. Here's our function for it, as well as definitions we need:

#define PIC_EOI     0x20        /* End-of-interrupt command code */

void pic_send_eoi(uint8_t irq){
	if(irq >= 8)
		outb(PIC2_COMMAND, PIC_EOI);
	
	outb(PIC1_COMMAND,PIC_EOI);
}

Now the final thing to do before we test is to add this:

    pic_remap(0x20, 0x28);
    asm volatile("sti");

To the end of our IDT init file. The latter being an instruction that activates interrupts. Now when we run our code, the timer should repeatedly trigger IRQ0. Since IRQ0 has been remapped to interrupt vector 32, the CPU will enter our irq0 handler, which eventually calls irq_handler(). We should therefore see IRQ repeatedly printed to the terminal. This is done by the timer. Which we will be writing drivers for next.