Exception handling should be one of the first things you write when writing an OS. I didn't do that. But luckily I had QEMU holding my hand throughout debugging, without which, I can imagine debugging would become quite the nightmare. The sad thing was, I didn't even write exception handling for debugging assistance. I thought I could write demand paging for page faults in kernelspace which wasn't as trivial as I thought. Anyways, back to exceptions.
An exception is a condition which requires a processor to halt normal execution and transfer control to a dedicated piece of software called an exception handler. Depending on the exception, a different exception handler will be used. On ARMv7, the different types of exceptions include: reset, interrupts, aborts, and exception generating instructions.
A special branch table of eight consecutive words called a vector table is the method in which control is transferred to an exception handler. The order of its entries, called exception vectors is significant as a processor uses an offset from the vector table's base to transfer control to the appropriate exception vector.
.global vector_table vector_table: b do_reset_stub b do_undefined_instruction_stub b do_supervisor_call_stub b do_prefetch_abort_stub b do_data_abort_stub b . b do_irq_interrupt_stub b do_fiq_interrupt_stub
Depending on whether a processor implements the Security Extensions, the semantics of a vector table can change. Some processors allow the vector table to reside at any address, while others require it to be at a fixed location. Furthermore, some processors have many vector tables, while others only have one. I was running my OS on a system with a Cortex-A15 processor which implemented the Security Extensions, therefore, I had a few more things to account for.
A combination of the SCTLR
and VBAR
registers determine the vector table's base address.
To achieve a similar memory layout to Linux, I choose to place my vector table at VECTOR_TABLE_VADDR
or
0xffff0000
. Well I say place, but what I really mean map. The below code below does
that and works similar to userspace's mmap
:
void map_vector_table() { create_mapping(VECTOR_TABLE_VADDR, virt_to_phys((uint32_t)&vector_table_begin), &interrupts_end - &vector_table_begin, BLOCK_RWX); }
When an exception is taken, the processor changes into a different mode depending on the exception's type. There are nine modes on ARMv7: User, System, Supervisor, Abort, Undefined, FIQ, IRQ, and Monitor. The kernel runs in supervisor mode and all exceptions are handled in supervisor mode.
Most modes have a set of banked registers, meaning that changes to those registers don't reflect across other modes; they are copies. This effectively allows each mode to maintain its own state. However, this also means that you need to take extra care when handling exceptions; we don't want the stack pointer to be pointing to just any address!
As well as changing modes, depending on the exception, some additional information is provided to the exception handler. For example, most exceptions provide return information to the exception handler. This additional information ensures the proper handling of exceptions and restoration of control flow.
Let's make things concrete. Consider the below code where an IRQ exception is generated in foo
:
void foo() { generate_irq_exception(); } void bar() { foo(); }
If we were to look at the processor's register information in QEMU's monitor just before the exception is generated, then it might look something like below:
R00=c00362a8 R01=c00362a8 R02=02000000 R03=ffc03000 R04=1c000402 R05=80000000 R06=00000000 R07=00000000 R08=00000000 R09=00000000 R10=00000000 R11=c0013fd4 R12=c0013f58 R13=c0013fd0 R14=c000a8ac R15=c000a86c PSR=20000013 --C- A S svc32
Some of these registers have special meanings. Importantly, R13
is the stack pointer, R14
is the link register which normally stores a function's return address, R15
is the program counter, and
PSR
is just QEMU's current program status register, cpsr
. The svc32
at the
bottom means that we're in in supervisor mode.
The state of all the registers from before must be preserved. Because as expected, immediately after the exception is generated, the processor's register information changes:
R00=c00362a8 R01=c00362a8 R02=02000000 R03=ffc03000 R04=1c000402 R05=80000000 R06=00000000 R07=00000000 R08=00000000 R09=00000000 R10=00000000 R11=c0013fd4 R12=c0013f58 R13=c0013ff8 R14=c000a884 R15=ffff0018 PSR=20000192 --C- A S irq32
As mentioned previously, we know that the processor will transfer control to an appropriate exception vector in the
vector table. In this case, it's the IRQ exception vector which in turn branches to
do_irq_interrupt_stub
:
do_irq_interrupt_stub: /* We're in IRQ mode. "lr" is the return address which we must decrement to return to the instruction which triggered the exception. We push this return address and the SPSR to the supervisor mode stack. */ sub lr, #4 srsfd sp!, #0x13 /* Switch to supervisor mode */ cps #0x13 /* Preserve the supervisor mode state. */ push {r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, fp, ip, lr} ldr r0, =do_irq_interrupt blx r0 /* Restore the supervisor mode state and return. */ pop {r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, fp, ip, lr} rfefd sp!
The first comment doesn't really explain why we must decrement the link register. I mentioned that additional
information can be provided to the exception handler. In this case the link register is updated to store the value
of the program counter when the exception was generated. However, when read, the program counter is eight bytes, or
two instructions ahead of the current instruction. Therefore, to get the next instruction, we subtract four. The
only non-trivial instructions are srs
, save return state, and rfe
, return from exception.
For our purposes they are the inverse of each other. The srs
instruction pushes the current program
status register and program counter to the stack at a a specified mode, and rfe
pops them.
With everything preserved on the stack, we then call do_irq_request
to handle the exception properly:
void do_irq_interrupt() { // Handle the exception properly. }
Right before the return from exception instruction, if we were to look at our registers they would look something like below:
R00=c00362a8 R01=c00362a8 R02=02000000 R03=ffc03000 R04=1c000402 R05=80000000 R06=00000000 R07=00000000 R08=00000000 R09=00000000 R10=00000000 R11=c0013fd4 R12=c0013f58 R13=c0013fc8 R14=c000a8ac R15=ffff006c PSR=20000193 --C- A S svc32
The only thing which is wrong is the link register and the current program status register. However, if we were to look at the stack, which the instruction is about to pop into the program counter, and current program status register, it would look something like the below:
c0013fc8: 0xc000a880 0x20000013 0xc0013fdc 0xc000a8ac
So after popping those two values, execution resumes in foo
, as if nothing happened.
The above example was rather reductive. In a mature operating system, unlike the toy one I've been writing, far more would actually happen. However, I guess transferring control and returning it as if nothing happened could be considered the essence of exception handling. A glorified function call.