RISC-V: A Baremetal Introduction using C++. Interrupt Handling.

RISC-V Machine Mode Interrupts

The RISC-V ISA is not specialized for embedded applications (when compared to an ISA such as the ARM Cortex-M). Keeping this in mind, the core ISA interrupt handing is limited — an interrupt controller is not in the core ISA specification.

The standard RISC-V ISA does not specify how to wire up a tangle of system interrupts.

C++ Callbacks

An interrupt is an asynchronous event. What do we do in C++ when an asynchronous event occurs?

        static const auto handler = [&] (void) 
auto this_cause = riscv::csrs.mcause.read();
// ...more code...
  1. A RISC-V interrupt handler must have a specific prologue to save context to the stack, and an epilogue to restore the stack and return via mret, unlike ARM Cortex-M, but like most ISAs, interrupts are not standard C functions.
  2. A RISC-V interrupt handler has alignment requirements.
  3. The C++ callback from the lambda function needs to be called as a method of a C++ object.

Installing an Interrupt Handler with GCC

We need to use some GCC compiler extensions when declaring functions used as interrupt handlers. The interrupt attribute and optimize pragma achieve the first two requirements above.

namespace irq {
static void entry(void)
__attribute__ ((interrupt ("machine")));
#pragma GCC push_options
// Force the alignment for mtvec.BASE.
#pragma GCC optimize ("align-functions=4")
static void entry(void) {
// Jump into the function defined within
// the irq::handler class.
#pragma GCC pop_options
riscv::csrs.mtvec.write(reinterpret_cast<std::uintptr_t>(irq::entry) );

Trampoline into C++

But how does this reach our lambda function? There is some need for C++ tricks and an optional RISC-V trick. We need to extract the function object context of the lambda function, we need to store it somewhere for the raw interrupt handler to use, and we need to call the lambda as a method of that object.

  • To store context for an the IRQ handler, we can use another machine mode register mscratch, although a global variable could also have been used.
  • To extract the lambda function object context, a templated function is used to access the generated lambda functor type. It’s important not to use std::function here, as std::function makes use of the heap.
  1. Create a static function irq::handler_entry() that calls an inlined static member _execute_handler.
  2. Assign _execute_handler to an intermediate void(*)(void) lambda function declared in the irq::handler constructor.
  3. Save a pointer to the handler’s functor object in the mscratch register.
  4. The intermediate _execute_handler lambda reads back the functor object pointer from mscratch and calls operator() on the pointer to the lambda functor object.
  5. The lambda function is invoked.
namespace irq {
class handler {
/** Create an IRQ handler class to install a
function as the machine mode irq handler */
template<class T> handler(T const &isr_handler);
inline static void (*_execute_handler)(void);
// Trampoline function is required to bridge
// from the entry point function declared with
// specific attributes and alignments to this class member.
friend void entry(void);
/* Step 1 */
static inline void handler_entry(void) {
template<class T> handler::handler(T const &isr_handler) {
// This will call the C++ function object method
// that represents the lambda function above.
// This is required to provide the context of
// the function call that is captured by the lambda.
// A RISC-V optimization uses the MSCRATCH register
// to hold the function object context pointer.
/* Step 2 */
_execute_handler = [](void)
// Read the context from the interrupt
// scratch register.
/* Step 4 */
uintptr_t isr_context = riscv::csrs.mscratch.read();
// Call into the lambda function.
/* Step 5 */
return ((T *)isr_context)->operator()();
// Get a pointer to the IRQ context and save
// in the interrupt scratch register.
uintptr_t isr_context = (uintptr_t)&isr_handler;
/* Step 3 */
reinterpret_cast<std::uintptr_t>(isr_context) );
// Write the entry() function to the mtvec register
/// to install our IRQ handler.
reinterpret_cast<std::uintptr_t>(entry) );


Could we implement this in pure C++? No, but it’s close.

  • For the caller, we’ve now reduced an interrupt handler to a standard C++ event driven programming model. This will allow the global state to be removed and possibly extended to other programming models such as promise/future.
  • But for the implementation, a traditional C callback would be much simpler to implement and understand. The C++ code to insert the handler is obfuscated and offers no abstraction.

The Disassembly

What happens when we compile the above? The interrupt service routine is below.

  • It loads the _execute_handler function address from 0x8000_0000into a5.
  • Saves the register context on the stack.
  • Calls _execute_handler by jumping (jalr) to the address in a5.
  • Restores the context from the stack.
  • Executes mretto exit the interrupt.
  • The context can be seen to be read from mscratch at 0x2001_0128 .
  • It’s clear that the handler lambda function has been inlined from when 0x2001_012c reads the mcause register.



Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Phil Mulholland

Phil Mulholland

Experienced in Distributed Systems, Event-Driven Systems, Firmware for SoC/MCU, Systems Simulation, Network Monitoring and Analysis, Automated Testing and RTL.