Skip to content

x86_64 IDT

The Interrupt Descriptor Table (IDT) is a data structure on the x86_64 and x86 architectures. It is used for associating interrupt requests with interrupt routines. The IDT consists of 256 vectors and contains three types of interrupt routines:

  • Exceptions: events generated by the CPU. They have a fixed mapping from the first and up to 32 interrupt vectors. Exceptions are mostly run-time errors in the program's code, and could be as trivial as a divide by zero or completely non-fixable during runtime like a segmentation fault, forcing you to kill the offending process.
  • Hardware interrupt/IRQ: events generated by hardware. The mapping depends on how the PIC is programmed. These are routines for things like a timer, the keyboard, or even a disk.
  • Software interrupt: events generated by the software. These are defined by the operating system. Linux usually maps syscalls to vector 0x80, however, you can map them anywhere you want as long as they don't conflict with exceptions or hardware mappings.

In real mode, the IDT is called the Interrupt Vector Table (IVT). The BIOS also provides simple interrupts for interacting with the hardware. It can also provide a memory map, which is the primary way for BIOS bootloaders to detect memory and pass it onto the operating system.

First of all, what is an interrupt?

An interrupt is generated by the hardware or software using the int instruction, with the argument being the interrupt vector. The interrupt itself is an event which immediately forces the CPU to stop whatever it is doing, and do something else instead. Most modern operating systems use interrupts for preemptive scheduling (Scheduling that is not cooperative; makes task switches happen regardless whether the current task wants it or not. The easiest way to implement it is by having scheduling happen during the timer interrupt), error handling, and system calls, which require changing CPU rings from ring 3 to ring 0, and interrupts do just that by default.

Pure C Based Interrupts

If you wish to avoid using assembly at all costs it is possible to handle interrupts purely in a high level language like C although it is not always the best way to handle things it can simplify things for making them not having to setup assembly stuff it can cause headaches and other issues if done incorrectly

Creating the structures

These structures are for the IDT itself to be filled in and loaded

struct __attribute__((packed)) IDTEntry {
    uint16_t offset_low;
    uint16_t selector;
    uint8_t  ist;
    uint8_t  type_attr;
    uint16_t offset_mid;
    uint32_t offset_high;
    uint32_t zero;
}

struct __attribute__((packed)) IDTR {
    uint16_t limit;
    uint64_t base;
};

static struct IDTEntry idt[256];
static struct IDTR idtr;
This structure is for the data that is passed to the interrupt by the CPU. This is not as much information that can be obtained using assembly but you can use inline assembly / naked functions yourself to get that data if needed later but you should just use Assembly Based Interrupts if that is wanted.
struct interrupt_frame {
    uintptr_t ip;
    uintptr_t cs;
    uintptr_t flags;
    uintptr_t sp;
    uintptr_t ss;
};

Filling Entries

This function lets you setup an IDT entry with a C function as the handler

void set_idt_entry(int index, int ist, int attr, void (*handler)()) {
    uint64_t addr = (uint64_t)handler;

    idt[index] = (struct IDTEntry){
        .offset_low = addr & 0xFFFF,
        .selector = 0x08,
        .ist = ist,
        .type_attr = attr,
        .offset_mid = (addr >> 16) & 0xFFFF,
        .offset_high = (addr >> 32),
        .zero = 0
    };
}
You can then use this function as follows to assign a C function to handle that interrupt
/*
0x20                 : ISR number (In this case being IRQ0 (the PIT) when the master PIC is remapped to 0x20)
0                    : The IST (The stack to be switched to based on the TSS, in most cases it should be 0)
0x8E                 : The attribute value which for kernel only interrupts should be 0x8E
(void (*)())pit_isr : The C function to be called whe the interupt is triggered
*/
set_idt_entry(0x20, 0, 0x8E, (void (*)())pit_isr);

Making C Interrupt Handlers

When making interrupts in pure C you need to use a special attribute __attribute__((interrupt)) for it to work and it will need to use a specific function signature being
void errorless_isr(void* frame) for interrupts that don't provide an error code and void double_fault_isr(struct interrupt_frame* frame, uint64_t error) for ones that do

As an example this interrupt could be assigned to the PIT (Covered Later)

volatile int pitInteruptsTriggered = 0;

__attribute__((interrupt))
void pit_isr(__attribute__((unused)) void* frame) {
    pitInteruptsTriggered++;
    outb(0x20,0x20);
}

Here the frame is unused so it is marked as such to tell the compiler and volatile is used to tell the compiler that the variable might be modified by an interrupt or another thread and lets it know to not store it in a register or do other optimizations to it so it works properly when referring to it externally.

You would want a header file for this function that marks that variable as external so other code can get the value and then every time the interrupt is triggered the value will increment and other code will see that happening so you can track time based on the PIT

Loading The IDT

The following code will set up the data structure to use with the lidt instruction and then execute it to load your IDT. This should be run after loading your entries into the IDT

    idtr.limit = sizeof(idt) - 1;
    idtr.base = (uint64_t)&idt;

    asm volatile ("lidt %0" : : "m"(idtr));
    asm volatile ("sti");
After this your interrupts should start to get triggered and your C code ran.

Assembly based interrupts which is the recommended way to do these gives you more control on how the C code is called so you can get more exact information like all of the registers and other things verses the limited set of data the CPU will give by default

Creating the structures

These structures are for the IDT itself to be filled in and loaded

struct __attribute__((packed)) IDTEntry {
    uint16_t offset_low;
    uint16_t selector;
    uint8_t  ist;
    uint8_t  type_attr;
    uint16_t offset_mid;
    uint32_t offset_high;
    uint32_t zero;
}

struct __attribute__((packed)) IDTR {
    uint16_t limit;
    uint64_t base;
};

static struct IDTEntry idt[256];
static struct IDTR idtr;
This is the structure that the (defined later) assembly stubs will pass to the C function that will give all the information needed to handle it
typedef struct {
    uint64_t r15, r14, r13, r12, r11, r10, r9, r8;
    uint64_t rbp, rdi, rsi, rdx, rcx, rbx, rax;
    uint64_t int_no, err_code;
    uint64_t rip, cs, rflags, rsp, ss;
} registers_t;

Assembly Stubs

When using assembly based interrupts you will assign IDT entries to the according stub made in assembly that will call a common stub to prepare data to then call a C function to get the interrupt number and then call the final proper handler for that interrupt.

The following assembly code will implement the macros to make stubs and the common stub that will call the C code
Make sure to have a C header file to refer to the things that you need too.

; Your exception handler here
extern isr_handler

; ===============================================
;     Macros
; ===============================================

; no error code (push a 0)
%macro ISR_NOERR 2
global do_isr%1
do_isr%1:
    cli ; disable interrupts
    push 0
    push %2
    jmp isr_common_stub
%endmacro

; error code
%macro ISR_ERR 2
global do_isr%1
do_isr%1:
    cli ;disable interrupts
    push %2
    jmp isr_common_stub
%endmacro

; ===============================================
; isr_common_stub
; ===============================================
isr_common_stub:
    ; This is the main stub everyone
    ; jumps to.

    ; Push registers
    push rax
    push rbx
    push rcx
    push rdx
    push rsi
    push rdi
    push rbp
    push r8
    push r9
    push r10
    push r11
    push r12
    push r13
    push r14
    push r15

    ; move the registers_t struct on 
    ; the stack to arguments which
    ; our ISR handler will use
    mov rdi, rsp

    ; call the C handler
    call isr_handler

    ; pop registers from the stack
    pop r15
    pop r14
    pop r13
    pop r12
    pop r11
    pop r10
    pop r9
    pop r8
    pop rbp
    pop rdi
    pop rsi
    pop rdx
    pop rcx
    pop rbx
    pop rax

    ; remove error code and num
    add rsp, 16
    iretq

; Map ISR macros to functions
; Each ISR can be referenced from C code as:
;  --------------------------------------------------------------
;  |   C Symbol     | Vector | Description                      |
;  |------------------------------------------------------------|
;  | do_isr0()      |   0     | Divide Error                    |
;  | do_isr1()      |   1     | Debug Exception                 |
;  | do_isr2()      |   2     | Non-Maskable Interrupt          |
;  | do_isr3()      |   3     | Breakpoint                      |
;  | do_isr4()      |   4     | Overflow                        |
;  | do_isr5()      |   5     | Bound Range Exceeded            |
;  | do_isr6()      |   6     | Invalid Opcode                  |
;  | do_isr7()      |   7     | Device Not Available            |
;  | do_isr8()      |   8     | Double Fault                    |
;  | do_isr9()      |   9     | Coprocessor Segment Overrun     |
;  | do_isr10()     |  10     | Invalid TSS                     |
;  | do_isr11()     |  11     | Segment Not Present             |
;  | do_isr12()     |  12     | Stack Fault                     |
;  | do_isr13()     |  13     | General Protection Fault        |
;  | do_isr14()     |  14     | Page Fault                      |
;  | do_isr15()     |  15     | Reserved                        |
;  | do_isr16()     |  16     | x87 Floating-Point Exception    |
;  | do_isr17()     |  17     | Alignment Check                 |
;  | do_isr18()     |  18     | Machine Check                   |
;  |------------------------------------------------------------|

; add more as needed for other ISRs
ISR_NOERR 0, 0
ISR_NOERR 1, 1
ISR_NOERR 2, 2
ISR_NOERR 3, 3
ISR_NOERR 4, 4
ISR_NOERR 5, 5
ISR_NOERR 6, 6
ISR_NOERR 7, 7
ISR_ERR   8, 8
ISR_NOERR 9, 9
ISR_ERR   10, 10
ISR_ERR   11, 11
ISR_ERR   12, 12
ISR_ERR   13, 13 
ISR_ERR   14, 14
ISR_NOERR 15, 15
ISR_NOERR 16, 16
ISR_ERR   17, 17
ISR_NOERR 18, 18

; PIT 32 = 0x20 in hex
ISR_NOERR 32, 32

Filling entries

This function lets you setup an IDT entry with a function as the handler

void set_idt_entry(int index, int ist, int attr, void (*handler)()) {
    uint64_t addr = (uint64_t)handler;

    idt[index] = (struct IDTEntry){
        .offset_low = addr & 0xFFFF,
        .selector = 0x08,
        .ist = ist,
        .type_attr = attr,
        .offset_mid = (addr >> 16) & 0xFFFF,
        .offset_high = (addr >> 32),
        .zero = 0
    };
}
You can then use this function as follows to assign a function to handle that interrupt
/*
0x20                 : ISR number (In this case being IRQ0 (the PIT) when the master PIC is remapped to 0x20)
0                    : The IST (The stack to be switched to based on the TSS, in most cases it should be 0)
0x8E                 : The attribute value which for kernel only interrupts should be 0x8E
(void (*)())do_isr32 : The C function to be called when the interrupt is triggered
*/
set_idt_entry(0x20, 0, 0x8E, (void (*)())do_isr32);

Writing the ISR handler

You can now make the void isr_handler (registers_t *regs) function in C that the assembly stub will call.
This function will need to check the ISR number and dispatch to the proper handler.

It should look similar to this

void isr_handler (registers_t *regs) {
    // add more cases for other ISRs as needed
    switch (regs->int_no) {
        case 0x08:
            double_fault_isr();
            break;
        case 0x20:
            pit_isr();
            break;
        default:
            generic_isr(regs->int_no, regs->err_code);
            break;
    }
}
The functions this will call should be made by you to do whatever is needed

Loading The IDT

The following code will setup the data structure to use with the lidt instruction and then execute it to load your IDT. This should be run after loading your entries into the IDT

    idtr.limit = sizeof(idt) - 1;
    idtr.base = (uint64_t)&idt;

    asm volatile ("lidt %0" : : "m"(idtr));
    asm volatile ("sti");
After this your interrupts should start to get triggered and your code ran.