mittos64/doc/8_Exceptions.md

8.0 KiB

Exceptions and Interrupts

Sometimes, things go wrong. When they do, we want to fail gracefully - or even recover. That's the point of exceptions.

Interrupt Service Routines

The x86 interrupt handling method is, for historical reasons I assume, messy. The x86_64 architecture saw a slight improvement in that the stack pointer and segment are always pushed, even if the cpu was running in ring 0 when the interrupt happened. Still, though, some exceptions push an error code, and others do not. And no data is provided to determine which interrupt occurred, besides which interrupt service routine was called.

If all interrupts pushed a dummy error code and an identifying number, a single ISR would be enough, and the rest could be done in software.

Anyway. Let's play with the cards we're dealt.

The most common way of solving this discrepancy is by having a number of short ISRs in the form

isr1:
  push 0 //; Dummy error code
  push 1 //; Interrupt number
  jmp isr_common //; The rest is the same for all interrupts

You may want up to 256 ISRs, so let's do some finger warmup exercises!

Or rather yet, let's generate the ISRs automatically. With python!

src/kernel/cpu/isr.S.py

#!/usr/bin/env python2
# -*- coding: utf-8 -*-

from __future__ import print_function

num_isr = 256
pushes_error = [8, 10, 11, 12, 13, 14, 17]

print('''
.intel_syntax noprefix
.extern isr_common
''')


print('// Interrupt Service Routines')
for i in range(num_isr):
    print('''isr{0}:
    cli
    {1}
    push {0}
    jmp isr_common '''.format(i,
        'push 0' if i not in pushes_error else 'nop'))

print('')
print('''
// Vector table

.section .data
.global isr_table
isr_table:''')

for i in range(num_isr):
    print('  .quad isr{}'.format(i))

This outputs an assembly file with 256 ISRs like the one above, except numbers 8, 10, 11, 12, 13, 14 and 17, which has an nop instruction instead of pushing a bogus error code.

It's written for python 2 because that's what's included in the alpine version the build docker image is based on - despite it being 2018. The encoding is utf-8, and I import the print function from __future__, because it's 2018.

It also makes a table with pointers to each ISR, which makes it easy to set up the Interrupt Descriptor Table later:

src/kernel/cpu/interrupts.c

...
struct idt
{
  uint16_t base_l;
  uint16_t cs;
  uint8_t ist;
  uint8_t flags;
  uint16_t base_m;
  uint32t base_h;
  uint32_t _;
}__attribute__((packed)) idt[NUM_INTERRUPTS];

extern uintptr_t isr_table[]

void interrupt_init()
{
  memset(idt, 0, sizeof(idt));
  for(int i=0; i < NUM_INTERRUPTS; i++)
  {
    idt[i].base_l = isr_table[i] & 0xFFFF;
    idt[i].base_m = (isr_table[i] >> 16) & 0xFFFF;
    idt[i].base_h = (isr_table[i] >> 32) & 0xFFFFFFFF;
    idt[i].cs = 0x8;
    idt[i].ist = 0;
    idt[i].flags = IDT_PRESENT | IDT_DPL0 | IDT_INTERRUPT;
  }
...

isr_common pushes all registers to the stack (one by one, there's no pusha instruction in x86_64) and passes controll to a c interrupt handler. Note that for x86_64 the arguments to a function is not primarily passed on the stack, but in registers. So the last thing it does before calling the c function is move the stack pointer into rdi. In case the handler returns, isr_common restores the stack pointer from rax - which is the function return value, pops all values again, and performs an iretq instruction, which is pretty much a backwards interrupt.

src/kernel/cpu/isr_common.S

...
isr_common:
  push r15
  push r14
...
  push rbx
  push rax
  mov rdi, rsp
  call int_handler

  mov rdi, rax
isr_return:
  mov rsp, rdi
  pop rax
  pop rbx
...
  pop r14
  pop r15
  add rsp, 0x10
  iretq

But what's the deal with passing rax to rsp via rdi? Doing it this way will allow us to call isr_return as a function, with a faked interrupt stack. We'll use this later to get into user mode.

Building isr.S.py

But back to the ISRs. In order to build this, we need some changes in the kernel makefile. First of all, the lines

SRC := $(wildcard **/*.[cS])
OBJ := $(patsubst %, %.o, $(basename $(SRC)))

need to be updated to allow more file extensions:

SRC := $(wildcard **/*.[cS]*)
OBJ := $(patsubst %, %.o, $(basename $(basename $(SRC))))

We also need a special rule to generate .o files from .S.py:

src/kernel/Makefile

%.o: %.S.py
	python $^ | $(COMPILE.S) $(DEPFLAGS) -x assembler-with-cpp - -o $@

In theory, it should be enough with a rule of the form

%.S: %.S.py
	python $^ > $@

However, this generates the dependency tree .o <- .s <- .S <- .py rather than .o <- .S <- .py, which uses as to compile, and causes some other trouble as well with intermediate files that are removed once, but not if you run make again, and stuff...

Some of this can be solved with an .INTERMEDIATE: rule, but that's not very elegant. The big problem's probably with me rather than make.

The Interrupt Handler

The c interrupt handler routine is a simple thing. Its default modus operandi is to print an error message and hang.

However, before doing this, it checks a table of other interrupt handlers, and if one exists for the current interrupt, it passes execution over to that.

src/kernel/cpu/interrupts.c

registers *int_handler(registers *r)
{
  if(int_handlers[r->int_no])
    return int_handlers[r->int_no](r);

  debug("Unhandled interrupt occurred\n");
  debug("Interrupt number: %d Error code: %d\n", r->int_no, r->err_code);
  debug_print_registers(r);

  PANIC("Unhandled interrupt occurred");
  for(;;);
}

Final Note

For tidyness sake, I wrapped the call to interrupt_init inside a function called cpu_init, which in turn is called from kmain. For now, that's all it is, but it will soon grow more important.

Bonus: Debugging Interrupts

There's a small problem with the way interrupts are handled by the processor; they don't follow the calling convention.

This means that when an interrupt occurs, and the debugger breaks in the PANIC macro, it has lost all context, and we can't see what happened.

But wait. The entire context is saved. It was pushed to the stack and passed to the interrupt handler. And by using gdbs ability to set the value of registers in qemu, we can bring it back into scope.

I put the following function in toolchain/gdbinit

define restore_env
set $name = $arg0
python

registers = {r: gdb.parse_and_eval('$name->' + r) for r in
['rax', 'rbx', 'rcx', 'rdx', 'rsi', 'rdi', 'rbp', 'rsp', 'r8', 'r9', 'r10',
'r11', 'r12', 'r13', 'r14', 'r15', 'rip']}

for r in registers.items():
  gdb.parse_and_eval('$%s=%s' % r)
gdb.execute('frame 0')
end
end

And it's used like this:

(gdb) c
Continuing.

Thread 1 hit Breakpoint 2, int_handler (r=0xffffff8000019f10) at cpu/interrupts.c:74
74        PANIC("Unhandled interrupt occurred");
(gdb) restore_env r
#0  0xffffff8000010caa in divide_two_numbers (divisor=0, dividend=0) at boot/kmain.c:18
18        return dividend/divisor;
(gdb) bt
#0  0xffffff8000010caa in divide_two_numbers (divisor=0, dividend=0) at boot/kmain.c:18
#1  0xffffff8000010dbd in kmain (multiboot_magic=920085129, multiboot_data=0x105fa0) at boot/kmain.c:33
#2  0xffffff8000010efd in .reload_cs () at boot/boot.S:96
#3  0x0000000000000007 in ?? ()
#4  0x0000000000000730 in ?? ()
#5  0x0000000000000000 in ?? ()
(gdb) list
13        for(;;);
14      }
15
16      int divide_two_numbers(int divisor, int dividend)
17      {
18        return dividend/divisor;
19      }
20
21      void kmain(uint64_t multiboot_magic, void *multiboot_data)
22      {
(gdb) p divisor
$1 = 0
(gdb) p divident
$2 = 5
(gdb) frame 1
#1  0xffffff8000010dbd in kmain (multiboot_magic=920085129, multiboot_data=0x105fa0) at boot/kmain.c:33
33        divide_two_numbers(0,5); // Calculate 0/5 and discard the results
(gdb)

By restoring the processor to the state stored in r, we can debug from where the interrupt occurred as normal. By backtracing and inspecting variables we find that whoever wrote line 33 in kmain.c got the divisor and divident mixed up, which resulted in a divide by zero exception.