ARM CPU exceptions in Tamago
During my journey through the ARMv7 architecture, I came across a really neat way of assigning exception handlers used in the Tamago Go compiler.
The ARMv7 Cortex-A programmer’s manual defines 8 class of exceptions which get raised in a plethora of situations, as a consequence of both user will and/or system error.
Basically every operating system must handle some (or all!) exceptions in order to execute its duty.
For example, the SVC
exception is usually used as a way for users to ask the operating system for some kind of service.
The programmer must tell the CPU what to do when exceptions are raised, and to do so it is their duty to declare exception handlers in a memory area called vector table, which is usually placed at the beginning of RAM.
The vector table holds references to routines associated with each exception, which in turn realize whatever the programmer thinks it’s appropriate to do.
The cool thing about ARM vector tables is that they don’t hold much data.
Table entries are just a word1 in size, so all they can actually do is point the execution to a label or just loop forever.
So when an exception happens, the CPU loads into the program counter (PC) the address that points to the instruction word stored at the corresponding exception type.
In the end, handling exceptions in ARMv7 boils down to allocating a big enough area of memory to hold enough words for every exception type.
How does Tamago does all of this?
First things first, this is the data structure used to represent a vector table:
// Table 11-1 ARM® Cortex™ -A Series Programmer’s Guide
type vector_table struct {
// starts at 0x0
reset uint32 // 0x0
undefined uint32 // 0x4
svc uint32 // 0x8
prefetch_abort uint32 // 0xc
data_abort uint32 // 0x10
_unused uint32 // 0x14
irq uint32 // 0x18
fiq uint32 // 0x1c
// call pointers
reset_addr uint32 // 0x20
undefined_addr uint32 // 0x24
svc_addr uint32 // 0x28
prefetch_abort_addr uint32 // 0x2c
data_abort_addr uint32 // 0x30
_unused_addr uint32 // 0x34
irq_addr uint32 // 0x38
fiq_addr uint32 // 0x3c
}
The following is the routine used to initialize it.
//go:nosplit
func vecinit() {
// Allocate the vector table
vecTableStart := ramStart + vecTableOffset
memclrNoHeapPointers(unsafe.Pointer(uintptr(vecTableStart)), uintptr(vecTableSize))
dmb()
vt = (*vector_table)(unsafe.Pointer(uintptr(vecTableStart)))
The function vecinit()
initializes the vector table at the beginning of the Tamago-built executable by initializing vt
, a global variable holding the table itself.
// ldr pc, [pc, #24]
resetVectorWord := uint32(0xe59ff018)
vt.reset = resetVectorWord
vt.undefined = resetVectorWord
vt.svc = resetVectorWord
vt.prefetch_abort = resetVectorWord
vt.data_abort = resetVectorWord
vt._unused = resetVectorWord
vt.irq = resetVectorWord
vt.fiq = resetVectorWord
resetVectorWord
seems like a random uint32 variable declaration but instead, it’s a generic exception handler trampoline used by the CPU to get to the real exception handler.
As the comment says, 0xe59ff018
is the binary representation of the instruction ldr pc, [pc, #0x18]
.
defaultHandlerAddr := **((**uint32)(unsafe.Pointer(&defaultHandler)))
simpleHandlerAddr := **((**uint32)(unsafe.Pointer(&simpleHandler)))
// We don't handle IRQ or exceptions yet.
vt.reset_addr = defaultHandlerAddr
vt.undefined_addr = defaultHandlerAddr
vt.prefetch_abort_addr = defaultHandlerAddr
vt.data_abort_addr = defaultHandlerAddr
vt.irq_addr = defaultHandlerAddr
vt.fiq_addr = defaultHandlerAddr
// SWI calls are also triggered by throw, but we cannot panic in panic
// therefore this handler needs not to throw.
vt.svc_addr = simpleHandlerAddr
if tamagoDebug {
print("vecTableStart ", hex(vecTableStart), "\n")
print("vecTableSize ", hex(vecTableSize), "\n")
}
set_vbar(unsafe.Pointer(vt))
}
They then place the effective handlers as pointer to functions in the rest of the vt
struct, and set the VBAR
register to the value of the pointer pointing at vt
.
Recall that whenever an exception happens, the CPU loads into the PC the address of the instruction associated with the exception type in the vector table, so for example when the SVC
exception happens, the PC assumes value vecTableStart+[offset of the SVC handler]
which in this case is 0x8
.
Since the vector_table
type holds both trampolines to handlers and the addresses to the handlers themselves, it makes absolute sense to use PC-relative addressing to jump to the correct handler, that is, using the program counter value as a base to which a displacement value is added: the displacement in this case is a fixed 0x18
immediate value.
So when SVC
gets raised the CPU reads the trampoline, which in turn set the PC to the value 0x00000010+0x18
, which in the end jumps to the routine pointed by vt.svc_addr
!
Even though the SVC
handler is two words distant from the beginning of the vector table in the ARM architecture the program counter is always 2 instruction word ahead, hence 0x10
.
The really neat thing about all of this it that it’s all Go.
This means that even a n00b like me can play around and hack its way into low-level development, and this means abstractions!
For example, the following is a naive implementation of a way to let the user set arbitrary exception handlers for the various exception types:
type ExceptionType int
const (
ExceptionTypeReset ExceptionType = iota
ExceptionTypeUndefined
ExceptionTypeSVC
ExceptionTypePrefetchAbort
ExceptionTypeDataAbort
ExceptionTypeIRQ
ExceptionTypeFIQ
)
type exceptionError string
func (ee exceptionError) Error() string {
return string(ee)
}
func SetExceptionHandler(t ExceptionType, h *ExceptionHandler) error {
if h == nil {
return exceptionError("cannot set nil exception handler")
}
hh := **((**uint32)(unsafe.Pointer(h)))
switch t {
case ExceptionTypeReset:
vt.reset_addr = hh
case ExceptionTypeUndefined:
vt.undefined_addr = hh
case ExceptionTypeSVC:
vt.svc_addr = hh
case ExceptionTypePrefetchAbort:
vt.prefetch_abort_addr = hh
case ExceptionTypeDataAbort:
vt.data_abort_addr = hh
case ExceptionTypeIRQ:
vt.irq_addr = hh
case ExceptionTypeFIQ:
vt.fiq_addr = hh
default:
return exceptionError("invalid exception type")
}
return nil
}
The caller would then invoke runtime.SetExceptionHandler()
to set a custom handler for a given exception type:
var mySvc runtime.ExceptionHandler = func() {
println("my svc called!")
for{}
}
func main() {
err := runtime.SetExceptionHandler(runtime.ExceptionTypeSVC, &mySvc)
if err != nil {
panic(err)
}
doSvc()
}
Given a valid exception type, the outcome is what we expect:
But when the type argument is wrong, an error gets returned and the whole program implodes:
All in all the whole “program bare-metal Go” looks very promising from a technical, cool-ness and productive point of view, and I must say the ARM architecture is documented well enough even for people like me who are just starting out with this world.
Update: after speaking with my friend Alessandro
about further abstractions, turns out you can actually use maps instead of a big switch
statement 👀
type ExceptionType int
const (
ExceptionTypeReset ExceptionType = iota
ExceptionTypeUndefined
ExceptionTypeSVC
ExceptionTypePrefetchAbort
ExceptionTypeDataAbort
ExceptionTypeIRQ
ExceptionTypeFIQ
)
var et = map[ExceptionType]*uint32{
ExceptionTypeReset: &vt.reset_addr,
ExceptionTypeUndefined: &vt.undefined_addr,
ExceptionTypeSVC: &vt.svc_addr,
ExceptionTypePrefetchAbort: &vt.prefetch_abort_addr,
ExceptionTypeDataAbort: &vt.data_abort_addr,
ExceptionTypeIRQ: &vt.irq_addr,
ExceptionTypeFIQ: &vt.fiq_addr,
}
type exceptionError string
func (ee exceptionError) Error() string {
return string(ee)
}
func SetExceptionHandler(t ExceptionType, h *ExceptionHandler) error {
if h == nil {
return exceptionError("cannot set nil exception handler")
}
hh := **((**uint32)(unsafe.Pointer(h)))
p, ok := et[t]
if !ok {
return exceptionError("invalid exception type")
}
*p = hh
return nil
}
32 bits in ARMv7 ↩︎