ARM CPU exceptions in Tamago · g/ianguid/o.today
Skip to main content

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.

exception_behavior_table.png

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:

okay-handler.png

But when the type argument is wrong, an error gets returned and the whole program implodes:

wrong-handler.png

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
}

  1. 32 bits in ARMv7 ↩︎