Skip to content

Latest commit

 

History

History
153 lines (124 loc) · 7.5 KB

exceptions.md

File metadata and controls

153 lines (124 loc) · 7.5 KB

RISC-V Exceptions

Raising an exception involves two things:

  • Modifying a bunch of state (namely numerous CSRs, potentially including mtval, mepc, mcause, etc.)
  • "Bailing out" of a cycle early, such that nothing that an instruction would do after an exception is raised actually happens.

Updating all the relevant state is a little tedious, especially since determining that right set of CSRs to modify (machine-mode or supervisor-mode) involves consulting medeleg or mideleg and the current privilege mode. We provide the helper functions raiseException and raiseExceptionWithInfo to alleviate this tedium.

Cutting the current cycle short is a bit involved. The details are covered in a later section, but suffice it to say here that ending the cycle early (via an exception) is like returning from a function early: anything prior to the exception actually happens, and nothing after the exception happens at all. The next section elaborates.

Practical Consequences

In hardware implementations, it's often the case that the effects of an instruction (such as writing to memory) don't actually go into effect or become permanent until the instruction is "committed" or "retired," in order to support out-of-order execution. This is not the case in our project; as soon as an instruction issues the command setRegister rd 42, register rd really is set to 42. If that instruction goes on to raise an exception, it will still be the case that rd contains 42. But in most cases, RISC-V instructions don't have their usual side effects when they raise exceptions, and so this behavior is rarely desired. Avoiding it requires care when describing instructions. Generally, this entails putting anything that could raise an exception up front, before setting registers, writing to memory, etc.

So: what functions can raise an exception?

raiseException and raiseExceptionWithInfo, of course, and anything that calls them:

translate tends to be simple enough to get right, as you'll usually call it prior to performing a store anyway. Likewise, it makes sense that checkPermissions always goes at the start of a CSR-related instruction, and it's not used outside of ExecuteCSR.hs. Just remember to be careful when calling setCSR and getCSR, and when raising an exception manually: unless you intentionally want to change state in a way that survives an exception, always make those calls before performing any writes or stores.

If you just want to know how to read or write code that involves RISC-V exceptions, feel free to stop reading. The remainder of this document deals with our implementation of RISC-V exceptions and how it might change in the future.

The Nitty Gritty

tl;dr: exceptions work via the MaybeT monad transformer, where intermediate Haskell knowledge will be required to follow the details.

The interface for a RISC-V machine is specified by the RiscvMachine type class, which is defined in Machine.hs. Part of this definition looks like:

class (Monad p, MachineWidth t) => RiscvMachine p t | p -> t where
  getRegister :: Register -> p t
  setRegister :: Register -> t -> p ()
  ...
  endCycle :: forall z. p z
  ...

The first line indicates that essentially one thing parameterizes a RiscvMachine: some monad p (which also implies some MachineWidth t). A particular instance of the RiscvMachine typeclass can be defined by specifying a specific Monad and MachineWidth and then defining functions (getRegister, setRegister, etc.) that satisfy the type signatures given in the type class.

As an example, I might have a monad called MState that models the state of a simple machine (with registers, memory, and so on). I can define an instance of RiscvMachine using this monad like instance RiscvMachine MState Int64 where ..., in which case p is MState and t is Int64. Substituting these into the typeclass, the definition of getRegister I provide had better have the type signature Register -> MState Int64, meaning a function that takes a Register and returns an Int64 wrapped in the MState monad.

With this in mind, the signature for endCycle might appear slightly mysterious. For all types z, endCycle returns a z wrapped in MState. So... for z as an Int, it returns MState Int. For z as a String, it returns an MState String. This may seem a little strange (an Int isn't a String, so a function returning an Int isn't a function that returns a String), but looking at the implementation of endCycle should clear things up.

Conspicuously, an implementation of endCycle is missing from Minimal64, but there is one given in Machine.hs:

instance (RiscvMachine p t) => RiscvMachine (MaybeT p) t where
  getRegister r = lift (getRegister r)
  setRegister r v = lift (setRegister r v)
  ...
  endCycle = MaybeT (return Nothing)
  ...

This instance demonstrates something powerful about our spec: composability. This instance takes some existing instance of a RiscvMachine and builds on it, adding in the functionality of the Maybe monad. If you're unfamiliar, in the Maybe monad, functions can return something or they can return Nothing, a special value. This is sort of like how methods in Java can always return some valid object or null. But in the Maybe monad, a computation halts as soon as something returns Nothing, and this precisely captures the "early-return" behavior we want upon encountering an exception.

So, endCycle returns Nothing (halting the computation), and all other functions do what they would normally do, just "lifted" into the new Maybe-infused form of the monad. This matches the type signature for endCycle, too: Nothing could be a Maybe Int, a Maybe String, or a Maybe anything else.

We use this kind of augmentation repeatedly in our code, sometimes to encode the semantics of a core feature (like RISC-V exceptions), and sometimes to add neat features (like memory-mapped I/O) to existing RiscvMachine instances.

Future Plans

Thus far, we believe being careful when ordering operations is sufficient to handle everything in the spec. However, if some hypothetical extension comes up that makes correct behavior with regard to exceptions more complex, it might be useful to have some construction that approximates the hardware practice of buffering state changes before they are finalized by a commit. That is, "do everything in this block, unless something raises an exception, in which case don't do any of it."

This could look something like:

execute (MyFancyInstruction rd rs1 rs2) = do
  -- Block 1
  transact do
    asdf <- getRegister rs2
    setRegister rs1 (asdf * 2)
    setCSR MyFancyCSR (asdf + 1)
  -- Block 2
  transact do
    setRegister rs2 42
    setCSR MyOtherCSR 117

In this example, either everything in block 1 happens, or setCSR raises an exception and we bail from the execution (and nothing happens to rs1). Then, if we didn't bail, either everything in block 2 happens, or setCSR raises an exception and we bail from the execution: in this case, nothing in block 2 happens (rs2 isn't changed), but everything in block 1 already happened.

We believe this should be relatively straightforward to implement by constructing a "phony" instance of RiscvMachine, but we haven't found it necessary yet.