RISC-V: A Bare-metal Introduction using C++. Machine Mode Timer.

Phil Mulholland
4 min readJun 7, 2021

--

This is the sixth post in a series, about the RISC-V machine mode timer and timing keeping using the C++ std::chrono library.

How does RISC-V keep time? How can we perform a periodic task with no operating system?

You may take for granted that you can simply ask the operating system to sleep and wake you up in a second. If you have programmed bare-metal systems, you’ll understand it’s not as straightforward as calling sleep().

The Machine Level ISA Timer

The RISC-V machine level ISA defines a real-time counter. It is defined as two MMIO system registers mtime and mtimer .

To get an interrupt one second from now, you simply need to set mtimecmp to mtime + 1 second.

The programming model is quite simple — when mtimecmpmtime you get an mti interrupt. The mtime register is a counter that increases monotonically - forever. The mtimecmp is continuously compared to it. As both registers are 64 bits there is no concern about overflow.

While most system registers are accessed via special instructions,mtime and mtimecmp, are accessed via MMIO (memory-mapped IO). This is because the mtime register depends on a global real-time clock and may need to be placed on a bus shared by many cores.

There is one remaining question, how do we know what 1-second corresponds to in mtime counts?

Timekeeping in Modern C++

Modern C++ includes the std::chrono library, and std::chrono::literals that allow us to think in terms of real time, not machine time. For embedded systems, time is a first-order concern, a benefit of C++ is that it makes it a standard part of the language.

Can we have a driver that simply lets as program “give me an interrupt in one second”?

Let’s look at the driver timer.hpp. We can start by defining the period of the mtime clock in C++ terms, via std::chrono::duration. This is a template as the mtime clock period is defined by the implementation. (For a SiFive device we can find the clock period and other parameters in the BSP device tree.)

The driver::timer::timer_ticks declaration is the period of mtime. It defines the period as a ratio.

namespace driver {
struct default_timer_config {
static constexpr unsigned int MTIME_FREQ_HZ=32768;
};
template<class CONFIG=default_timer_config> class timer {
/** Duration of each timer tick */
using timer_ticks = std::chrono::duration<int,
std::ratio<1, CONFIG::MTIME_FREQ_HZ>>;
}
}

Next, how can we convert these timer ticks to another time base? std::chrono::duration_cast does the job. The expression std::chrono::duration_cast<timer_ticks>(time_offset) gives the ratio of the number of seconds to clocks in one second.

If we have a timer value from mtime and want to convert to microseconds, then we use:

uint64_t value_from_mtime = ...;
auto value_in_ms =
std::chrono::duration_cast<std::chrono::microseconds>(
driver::timer::timer_ticks(value_from_mtime));

Alternatively, to convert from microseconds to a hardware timer value for mtimecmp then we use:

auto time_offset = std::chrono::microseconds(???);
uint64_t value_of_mtimecmp = std::chrono::duration_cast<timer_ticks>
(time_offset).count();

It’s all computed at compile-time, so no run-time cost is incurred.

Reading/Writing MMIO Registers in C++

There is not much difference between accessing MMIO registers in C, and C++. One advantage C++ has is templates. As RISC-V’s timer registers are not at a fixed address (absolute or relative to each other), re-usable code should be parameterized. Here that is done via template parameters.

struct mtimer_address_spec {
static constexpr std::uintptr_t
MTIMECMP_ADDR = 0x2000000 + 0x4000;
static constexpr std::uintptr_t
MTIME_ADDR = 0x2000000 + 0xBFF8;
};
template<class ADDRESS_SPEC=mtimer_address_spec>
void set_raw_time_cmp(uint64_t clock_offset) {
// Single bus access
auto mtimecmp = reinterpret_cast<volatile std::uint64_t *>
(ADDRESS_SPEC::MTIMECMP_ADDR);
*mtimecmp = *mtimecmp + clock_offset;
}

In C we could use a structure to define the location of each register with a run time cost, or a set of pre-processor macros to make this zero-cost, however, in C++ we can pass a structure via a template parameter at zero cost.

Conclusion

The timer driver covers a few core topics in bare-metal programming and how C++ can provide an advantage.

  • MMIO access and static polymorphism.
  • Hardware real-time clocks.
  • Converting clock frequencies and periods to human-readable units.
  • Configuring drivers via templates and constexpr.

The next post will look at handling interrupts.

64 Bit Registers Access on a 32 Bit Bus

There is a small complication accessing timer registers, they are 64 bits wide and time tends to update constantly while our program is executing. On a 32 bit system, we can only access 1/2 of the register at a time.

Imagine this sequence.

  1. The mtime is 0x0000_0000_FFFF_FFFF.
  2. We read the top 32 bits, 0x0000_0000
  3. We save this into our register t0.
  4. The real time clock ticks.
  5. The mtime is 0x0000_0001_0000_0000.
  6. We read the bottom 32 bits, 0x0000_0000.
  7. We save this into our register t1.
  8. We check the time in t0:t1, it’s 0x0000_0000_0000_0000!

This is one of the problems with bare-metal programming, we are often communicating with hardware devices that are operating asynchronously to out software.

What can we do to deal with this? The upper bytes in mtime are very unlikely to change from read to read, so we can loop while there is a difference between reads. As the variable is marked volatile the compiler knows to keep reading it from "memory" each time. (The one acceptable use of volatile in C++...)

There are similar issues writing to mtimecmp that can cause spurious interrupts. Fortunately, the RISC-V spec gives us an example of the code required to avoid this issue ..... in RISC-V assembly.

--

--

Phil Mulholland

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