This post is a draft for Medium.
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:
auto this_cause = riscv::csrs.mcause.read();
riscv::csrs.mie.mti.set();
riscv::csrs.mtvec.write( reinterpret_cast<std::uintptr_t>(irq_vector));
RISC-V Special Instructions and C++
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 lets 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 : from register */
: /* 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 traits like structure
mtvec_ops {}
.
This will enable us to do some 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
You’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 : register */
: /* 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 : register */
: /* 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, completely. 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.