Skip to content

Hardware Register Mapping with Quantity Points

Try it live on Compiler Explorer

Overview

This example demonstrates how to use quantity_point and custom origins to model hardware measurements with both scaling and offset. It implements a realistic embedded scenario where a 16-bit hardware register maps the voltage range [\(-10\ \mathrm{V}\), \(10\ \mathrm{V}\)] to raw values [\(-32767\), \(32767\)].

As explained in The Affine Space, measurements should be modeled as quantity_point rather than plain quantity when they represent absolute values on a scale (like temperature, voltage level, or position).

Key Features Demonstrated

  • Creating custom quantity point origins
  • Defining offset units with scaling factors
  • Handling hardware register mappings in embedded systems
  • Converting between hardware units and SI units

Code Walkthrough

Defining the Voltage Range

We start by defining the actual measurement voltage range:

hw_voltage.cpp
// real voltage range
inline constexpr int min_voltage = -10;
inline constexpr int max_voltage = 10;
inline constexpr int voltage_range = max_voltage - min_voltage;

Next, we define the hardware representation characteristics:

hw_voltage.cpp
// hardware encoding of voltage
using voltage_hw_t = std::uint16_t;
inline constexpr voltage_hw_t voltage_hw_error = std::numeric_limits<voltage_hw_t>::max();
inline constexpr voltage_hw_t voltage_hw_min = 0;
inline constexpr voltage_hw_t voltage_hw_max = voltage_hw_error - 1;
inline constexpr voltage_hw_t voltage_hw_range = voltage_hw_max - voltage_hw_min;
inline constexpr voltage_hw_t voltage_hw_zero = voltage_hw_range / 2;

Creating a Custom Origin and Offset Unit

The key part is defining a custom origin and an offset unit that handles both scaling and translation:

hw_voltage.cpp
inline constexpr struct hw_voltage_origin final :
  relative_point_origin<point<si::volt>(min_voltage)> {} hw_voltage_origin;

inline constexpr struct hw_voltage_unit final :
  named_unit<"hwV", mag_ratio<voltage_range, voltage_hw_range> * si::volt, hw_voltage_origin> {} hw_voltage_unit;

using hw_voltage_quantity_point = quantity_point<hw_voltage_unit, hw_voltage_origin, voltage_hw_t>;

Here's what's happening:

  1. hw_voltage_origin: A custom point origin representing -10 V (the hardware's zero point)
  2. hw_voltage_unit: An offset unit that:
    • Scales by the conversion factor (20 V / 65534 counts)
    • Uses hw_voltage_origin as its origin
  3. hw_voltage_quantity_point: A quantity_point type using this offset unit

This setup automatically handles both the scaling (converting counts to volts) and the offset (shifting the zero point from -10 V to 0 V).

Reading Hardware Values

Now we can simulate reading from a hardware register:

hw_voltage.cpp
// mapped HW register
volatile voltage_hw_t hw_voltage_value;

std::optional<hw_voltage_quantity_point> read_hw_voltage()
{
  voltage_hw_t local_copy = hw_voltage_value;
  if (local_copy == voltage_hw_error) return std::nullopt;
  return point<hw_voltage_unit>(local_copy);
}

Working with Volatile Hardware Registers

The hardware register is declared as volatile to prevent compiler optimizations that could eliminate or reorder hardware reads:

volatile voltage_hw_t hw_voltage_value;  // Declare as volatile

Why the local copy?

Direct use of volatile values in expressions can cause multiple hardware reads. The pattern used here:

  1. Makes exactly one read from the hardware register into a non-volatile local variable
  2. Checks for error conditions (invalid hardware state)
  3. Constructs the quantity point only if the value is valid

This is the standard embedded practice for working with hardware registers:

voltage_hw_t local_copy = hw_voltage_value;  // Single volatile read
if (local_copy == voltage_hw_error) return std::nullopt;
return point<hw_voltage_unit>(local_copy);   // Use non-volatile copy

Key Benefits:

  • Predictable behavior: Exactly one hardware access per call
  • Efficiency: No redundant register reads
  • Safety: Error checking before constructing typed quantities
  • Library compatibility: quantity_point constructors work with non-volatile values

Volatile and mp-units

The mp-units library works seamlessly with values read from volatile memory. Simply copy the volatile value to a local variable before constructing quantities or quantity points. This pattern is standard in embedded systems and provides the best balance of safety and efficiency.

Output

The example reads three hardware values and displays them in both hardware units and SI volts:

hw_voltage.cpp
int main()
{
  // simulate reading of 3 values from the hardware
  hw_voltage_value = voltage_hw_min;
  quantity_point qp1 = read_hw_voltage().value();
  hw_voltage_value = voltage_hw_zero;
  quantity_point qp2 = read_hw_voltage().value();
  hw_voltage_value = voltage_hw_max;
  quantity_point qp3 = read_hw_voltage().value();

  print(qp1);
  print(qp2);
  print(qp3);
}

Output:

     0 hwV (-10 V)
 32767 hwV (  0 V)
 65534 hwV ( 10 V)

Notice how:

  • Hardware value 0 corresponds to -10 V (the minimum of the range)
  • Hardware value 32767 corresponds to 0 V (the midpoint)
  • Hardware value 65534 corresponds to 10 V (the maximum of the range)

Why Quantity Points Matter

This example illustrates why quantity_point is essential for real-world measurements:

  1. Prevents Errors: You can't accidentally add two absolute voltage measurements (e.g., 5 V + 3 V doesn't make physical sense for absolute readings)
  2. Handles Offsets: The zero of the hardware scale differs from the zero of the physical scale
  3. Type Safety: The compiler ensures correct conversions between coordinate systems
  4. Embedded-Friendly: Minimal runtime overhead while maintaining safety

Practical Applications

This pattern is useful for:

  • ADC readings with offset calibration
  • Temperature sensors (especially with non-zero reference points)
  • Position encoders with custom datum points
  • Pressure sensors with atmospheric offset
  • Any measurement device with non-standard zero reference