RISC-V: A Baremetal Introduction using C++. System Registers.

Phil Mulholland
4 min readJun 7, 2021

--

This is the fifth post in a series.

What are system registers in RISC-V? How can we access them with modern C++?

System registers require special instructions to access, so unlike memory-mapped registers (MMIO) we can’t just cast a pointer to memory to get access them in C++.

Do we need to embed inline assembly in our code, destroying the flow of our clean C++? No, with some abstraction we can write code like this:

RISC-V Special Instructions and C++

auto this_cause = riscv::csrs.mcause.read();
riscv::csrs.mie.mti.set();
riscv::csrs.mtvec.write(
reinterpret_cast<std::uintptr_t>(irq_vector));

How does the above code generate custom instructions? The riscv-csr.hpp header provides the abstractions. That’s a huge file, but it is generated from a much more compact template file templates/riscv-csr.hpp.

To understand how it works let's look at how just one system register, such as mtvec, can be written. The csrw instruction will write to the register, and the assembler can recognize mtvec and encode it to register number 0x0305.

GCC inline assembler is required, but we can hide it within a static inline method of a struct.

namespace riscv {
namespace csr {
#if __riscv_xlen==32
using uint_xlen_t = std::uint32_t;
#elif __riscv_xlen==64
using uint_xlen_t = std::uint64_t;
#endif
struct mtvec_ops {
using datatype = uint_xlen_t;
static constexpr priv_t priv = MRW;
static void write(uint_xlen_t value) {
__asm__ volatile ("csrw mtvec, %0"
: /* output: none */
: "r" (value) /* input*/
: /* clobbers: none */);
}
}
}
}

The function above is fine, and we could use it as-is, but it’s not very C++-ish (modern or classic), and it will get messy once we try and write immediate values, or do atomic write to bitfields in CSRs. (Those have their own instructions such as csrwi and csrrw).

You may have seen the function was declared with a generic name, write(), and not mtvec_write() or something specialized. Instead, the target system register was scoped was provided by a traits-like structure, mtvec_ops {} .

This will enable us to do some generic programming. We can achieve that by declaring similar structs with methods of the same name, write(), for all special instructions. Then we use the structure as a template parameter to a generic register access class, such as read_write_reg.

Finally, this templated class read_write_reg<mtvec_ops> can be aliased via using and we can give it a simple name, such as mtvec_reg.

For simplicity, the whole read_write_reg class is not shown here, just the write function and instantiation.

namespace riscv {
namespace csr {
template<class C> class read_write_reg {
public :
using write_datatype_t = typename C::datatype;
/** Write to the CSR. */
inline void write(const write_datatype_t value) {
C::write(value);
}
};
// Instantiate the read_write_reg class
// with the mtvec operations
using mtvec_reg = read_write_reg<mtvec_ops>;
// ...
struct all {
riscv::csr::mtvec_reg mtvec;
}
} /* csr */
static csr::all csrs;
} /* riscv */

We can now write to the interrupt vector in clean C++ code.

riscv::csrs.mtvec.write( reinterpret_cast<std::uintptr_t>(entry));

RISC-V CSRs and Bit Level Access

We’ve now seen how mtvec.write( ...); works. How about accessing fields?

RISC-V has a set of atomic read and write or set/clear bits instructions. These can be used to modify fields of system registers.

As you would expect, with the same method as above C++ can abstract those instructions. Using constexpr we can use compile-time conditional code to select instructions.

namespace riscv {
namespace csr {
struct mie_ops {
/** Atomic modify and set bits for mie */
static void set_bits(uint_xlen_t mask) {
__asm__ volatile ("csrrs zero, mie, %0"
: /* output: none */
: "r" (mask) /* input */
: /* clobbers: none */);
}
/** Atomic modify and set bits from immediate for mie */
static void set_bits_imm(const uint8_t mask) {
__asm__ volatile ("csrrsi zero, mie, %0"
: /* output: none */
: "i" (mask) /* input */
: /* clobbers: none */);
}
}; /* mie_ops */
template<class C, class F> class read_write_field {
public:
inline void set(void) {
if constexpr ((F::BIT_MASK & CSR_IMM_OP_MASK) ==
F::BIT_MASK) {
C::set_bits_imm(F::BIT_MASK);
} else {
C::set_bits(F::BIT_MASK);
}
}
};
/* Machine Status */
template<class OPS> class mstatus_reg :
public read_write_reg<OPS>
{
public:
read_write_field<OPS,
riscv::csr::mstatus_data::mie> mie;
};
using mstatus = mstatus_reg<riscv::csr::mstatus_ops>;;
} /* csr */
} /* riscv */

While that is very verbose, it is easy to generate. We can now enable the interrupt vector in clean C++ code.

// Global interrupt enable
riscv::csrs.mstatus.mie.set();

Conclusion

So were we able to do this in pure C++? Yes (with a touch of inline assembly). In fact, using C++ has opened up programmable compile-time optimizations implemented at the instruction level that would not be possible in C.

As for RISC-V? Another topic that can be explored using this method is custom instruction extensions. I will explore that later.

The next post will explore the machine mode timer.

--

--

Phil Mulholland

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