Introduction

Generic Register Operation Optimizer (groov) is a library that abstracts read and write operations on registers in baremetal embedded systems. Some important features of groov are:

  • Abstraction through separation of name and layout: where possible, registers and fields are referenced by name and manipulated with strong types rather than by poking bits into the layout.

  • Awareness of asynchronous operation: read and write operations are not just assumed to be synchronous to memory, but are modelled with senders to allow for asynchronous event-driven scenarios. Synchronous behaviour is still supported since it is a subset of asynchronous behaviour.

  • Error handling: the same mechanism that allows read and write operations to be asynchronous also provides a path for errors.

Dependencies

Registers and Fields

Registers and fields (reg and field types) define data layout. They are empty types. A register represents an addressable unit of storage, which contains fields located in certain bit ranges. Fields may in turn contain (sub)fields.

Registers and fields are quite similar with similar interfaces. They are characterized by:

  • the name

  • the type

  • the location (in fields, a bit range; in registers, an address)

  • the write function

  • any contained fields or subfields

// two subfields located at [1:0] and [3:2]
using my_sub_field_0 = groov::field<"sub_field_0", std::uint32_t, 1, 0>;
using my_sub_field_1 = groov::field<"sub_field_1", std::uint32_t, 3, 2>;

// a field located at [3:0] containing the above subfields
using my_field_0 = groov::field<"field_0", std::uint32_t, 3, 0, groov::w::replace,
                                my_sub_field_0, my_sub_field_1>;
// a field located at [7:4]
using my_field_1 = groov::field<"field_1", std::uint32_t, 7, 4>;

// a register (address 0xa'0000) containing the above fields
using my_reg = groov::reg<"reg", std::uint32_t, 0xa'0000, groov::w:replace,
                           my_field_0, my_field_1>;

Types

Both registers and fields expose a type_t alias indicating the type of the data. For registers, this is an unsigned integral type suitable for bitwise operations. The type of a field is not so constrained and represents the "API" type.

Note
At the moment it is assumed that fields occupy contiguous storage entirely within a register.

Masks

Both registers and fields expose a mask that indicates their bit extent. For registers, this is equivalent to the maximum value of their type_t.

Extract and insert

Both registers and fields implement extract and insert operations.

template <std::unsigned_integral T>
[[nodiscard]] constexpr static auto extract(T value) -> type_t;

template <std::unsigned_integral T>
constexpr static void insert(T &dest, type_t value) -> void;

Paths

A path is a way to access a field or register, just like a filesystem path accesses an object within a filesystem. It is a compile-time list of strings.

Construction and manipulation

Paths can be constructed using UDLs which are in the groov::literals namespace.

using namespace groov::literals;

auto p1 = "reg"_r;
auto p2 = "field"_f;
Note
The _r and _f literals are equivalent; they are intended merely to signal "register-like" and "field-like" respectively.

Multi-part paths can be constructed using . as a separator. Paths can also be manipulated with operator/.

auto p1 = "reg"_r;
auto p2 = "field"_f;

// these are all equivalent
auto p3 = p1 / p2;
auto p4 = "reg.field"_f;
auto p5 = groov::path<"reg", "field">{};

Attaching values

Paths may exist as empty objects, carrying information in the type, or they may have values attached.

using namespace groov::literals;

// type information only
auto p1 = "reg.field"_r;
// with a value attached
auto p2 = "reg.field"_r = 42;

Generally, type information is used for reading, and attached values are used for writing.

Note
Paths carry no layout information; a path with a value represents only a generic binding. It is not converted into a type with layout until it is combined with a group for writing.

Resolving

Paths on their own are just empty types. Their real power is as an abstraction that affords resolution. Any object may resolve a path by providing a resolve member function:

struct reg {
  // ...
  template <pathlike P> constexpr static auto resolve(P p);
};

Registers and fields can both resolve paths.

using my_field = groov::field<"field", std::uint32_t, 3, 0>;
using my_reg = groov::reg<"reg", std::uint32_t, 0xa'0000, my_field>;

// f has type my_field
auto f = groov::resolve(my_reg{}, "reg.field"_f);

In fact, registers and fields can resolve paths that don’t start "at the root", as long as they are unambiguous.

using my_sub_field_0 = groov::field<"sub_field", std::uint32_t, 1, 0>;
using my_field_0 = groov::field<"field_0", std::uint32_t, 3, 0, my_sub_field_0>;

using my_sub_field_1 = groov::field<"sub_field", std::uint32_t, 5, 4>;
using my_field_1 = groov::field<"field_1", std::uint32_t, 7, 4>;

using my_reg = groov::reg<"reg", std::uint32_t, 0xa'0000, my_field_0, my_field_1>;

// "field_1" is unambiguous within "reg": f has type my_field_1
auto f = groov::resolve(my_reg{}, "field_1"_f);

// "sub_field" is ambiguous within "reg": subf has type groov::ambiguous_t
auto subf = groov::resolve(my_reg{}, "sub_field"_f);

And of course, paths themselves can resolve paths.

auto p1 = "a.b.c.d"_r;

// p2 is "c.d"
auto p2 = groov::resolve(p1, "a.b");

Path resolution can fail in a few ways:

  • ambiguity, as seen above (result: groov::ambiguous_t)

  • the path was too long to resolve (result: groov::too_long_t)

  • plain not found (result: groov::mismatch_t)

All of these failure types derive from groov::invalid_t. There are utilities to deal with resolution in generic code:

// free function resolve (calls t.resolve)
template <typename T, pathlike Path, typename... Args>
constexpr auto resolve(T const &t, Path const &p, Args const &...args) -> decltype(auto);

// static_assert if resolution fails, indicating how it failed
template <typename T, pathlike Path, typename... Args>
constexpr auto checked_resolve(T const &t, Path const &p, Args const &...args) -> decltype(auto);

// what type is returned from resolve?
template <typename... Ts>
using resolve_t = decltype(resolve(std::declval<Ts>()...));

// can_resolve is modelled if resolve_t<Args...> is not an invalid_t
template <typename... Args>
concept can_resolve = /* ... */;

// type trait equivalents to the concept can_resolve
template <typename... Args>
constexpr static bool is_resolvable_v = /* ... */;

template <typename... Args>
using is_resolvable_t = /* ... */;

Groups

A group collects registers together with a bus. A bus provides read and write functionality. Like registers and fields, groups can resolve paths.

struct bus { /* ... */ };

using F = groov::field<"field", std::uint32_t, 0, 0>;
using R = groov::reg<"reg", std::uint32_t, 0, F>;
using G = groov::group<"group", bus, R>;

// r has type F
auto r = groov::resolve(G{}, "reg.field"_f);

Combining groups with paths

The combination of a group and one or more paths forms a specification for reading or writing.

constexpr auto grp = G{};

// Use operator/ to combine a single path
auto r_spec = grp / "reg"_r;
auto w_spec = grp / ("reg"_r = 42);

// alternatively, or for multiple paths:
auto r_spec = grp("reg1"_r, "reg2"_r);
auto w_spec = grp("reg1"_r = 42, "reg2"_r = 17);

Reading and writing

Read and write specifications

If a group is combined with paths without values, the result is a read_spec. If a group is combined with paths with values, the result is a write_spec.

A read_spec is an empty object which carries in its type all the information necessary for a read. A write_spec is an object carrying similar type information, but also with register data in the correct layout, ready for writing.

// this does no runtime work, just composing types
auto r_spec = grp / "reg"_r;

// this packs values into register layouts
auto w_spec = grp / ("reg"_r = 42);
Note
If any bound paths are not resolvable or are ambiguous in the group, a compile-time error is produced.

A write_spec supports indexing, for retrieving or updating values.

auto w_spec = grp / ("reg"_r = 42);
w_spec["reg"_r] = 17;          // update value
assert(w_spec["reg"_r] == 17); // retrieve value

Updates are not limited to reassignment; operator[] returns a proxy object with all the compound assignment operators defined on it, as well as increment and decrement operators.

A write_spec may be indexed with any path that is resolvable by a bound path.

auto w_spec = grp / ("reg"_r = 42); // "reg" is bound
w_spec["reg.field"_f] = 1;          // OK, assuming "reg" contains "field"

As a short cut, if only one value is specified, a write_spec can implicitly convert to that value. Note that this is read-only; the converted value is not a reference that can be used for update.

auto w_spec = grp / ("reg"_r = 42);
assert(w_spec == 42); // implicit conversion

Read

The read function takes a read_spec and produces a sender.

auto r = groov::read(grp / "reg"_r);

It does this by:

  • computing a mask for each register that is read, according to the fields

  • for each register, calling read on the bus with the address and mask

  • executing when_all on the resulting senders

  • turning the results into a write_spec

Read can also be piped in a chain of senders.

auto r = async::just(grp / "reg"_r) | groov::read | async::sync_wait();

Write

The write function takes a write_spec and produces a sender.

auto r = groov::write(grp("reg"_r = 42));

It does this by:

  • computing masks and identity values for each register that is written, according to the fields

  • for each register, calling write on the bus with the address, masks and value

  • executing when_all on the resulting senders

Write can also be piped in a chain of senders.

auto r = async::just(grp("reg"_r = 42)) | groov::write | async::sync_wait();
Note
read and write are function objects, hence in a chain they appear as e.g. groov::read rather than groov::read().

Because read sends a write_spec, a read-modify-write can be achieved with piping:

async::just(grp / "reg"_r)
    | groov::read
    | async::then(
      [](auto spec) { spec["reg"_r] ^= 0xffff'ffff;
                      return spec; })
    | groov::write
    | async::sync_wait();

Buses

groov is a library for orchestrating and managing read and write operations efficiently, but it can’t actually perform the reads and writes to hardware. That’s what a bus is for. A bus is groov​'s interface with lower levels and it is used by read and write. So it has its own read and write API.

struct bus {
  template <auto Mask>
  static auto read(auto addr) -> async::sender auto;

  template <auto Mask, auto IdMask, auto IdValue>
  static auto write(auto addr, auto value) -> async::sender auto;
};

To provide for asynchronous behaviour, a bus’s read and write functions return senders. These can of course be async::just_result_of senders in the case of a synchronous operation like memory-mapped IO.

Both read and write operations receive a compile-time mask of what is being read or written, so that they can optimize best according to hardware capabilities. For write, bits set in Mask are the bits that must be written, and bits set in IdMask may be safely written using the corresponding bits in IdValue.

Note
Mask and IdMask are disjoint sets of bits. Therefore Idvalue and value are also disjoint sets of bits. Bits that are zero in IdMask are also zero in IdValue.

Write Functions

Each field and register is parameterized with a write function. By default, the write function is groov::w::replace.

using my_field = groov::field<"field", std::uint32_t, 3, 0, groov::w::replace>;

Write functions

Built-in write functions are in the groov::w namespace. Examples are:

  • replace ("normal write")

  • read_only

There are 16 possible write functions, each characterized by a truth table relating the current value C, the written value W and the result R. For example:

Table 1. Truth table for replacement
C W R

0

0

0

0

1

1

1

0

0

1

1

1

Table 2. Truth table for read-only
C W R

0

0

0

0

1

0

1

0

1

1

1

1

Identity specs

Each write function may have an identity spec indicated by its truth table and exposed as the alias id_spec. For replacement, the identity spec is none. For read-only, the identity spec is any. There are 4 possible identity specs:

  • none

  • zero

  • one

  • any

Each identity spec (except none) has a function that computes the identity for a field according to its bit range:

template <std::unsigned_integral T, std::size_t Msb, std::size_t Lsb>
constexpr static auto identity() -> T;

Building the write masks

In general a given write to a register will only want to write some of the field values. The other field values are left unspecified. The write machinery uses the field write functions and their associated identity specs to compute:

  • the write mask for the fields to be written

  • the identity mask for the other fields

  • the identity value (if any) for the other fields

For example:

// the default write function is groov::w::replace
using my_field_0 = groov::field<"field_0", std::uint32_t, 9, 0>;
using my_field_1 = groov::field<"field_1", std::uint32_t, 31, 10>;
using my_reg = groov::reg<"reg", std::uint32_t, 0xa'0000, groov::w::replace,
                           my_field_0, my_field_1>;

// when writing only field_1:
write(grp("my_reg.field_1"_f = 42));

// results in a call to bus::write as if:
constexpr auto write_mask = 0xffff'fc00u; // mask for field_1
constexpr auto identity_mask = 0u;        // field_0 has id_spec none
constexpr auto identity_value = 0u;       // field_0 has id_spec none

// the write mask and the identity mask don't cover the range of the register,
// so the bus must read-modify-write to preserve unwritten bits
bus::template write<write_mask, identity_mask, identity_value>(/* ... */);

But if the unwritten field(s) are read-only (or otherwise have an identity):

using my_field_0 = groov::field<"field_0", std::uint32_t, 9, 0, groov::w::read_only>;
using my_field_1 = groov::field<"field_1", std::uint32_t, 31, 10>;
using my_reg = groov::reg<"reg", std::uint32_t, 0xa'0000, groov::w::replace,
                           my_field_0, my_field_1>;

// when writing only field_1:
write(grp("my_reg.field_1"_f = 42));

// results in a call to bus::write as if:
constexpr auto write_mask = 0xffff'fc00u;    // mask for field_1
constexpr auto identity_mask = 0x0000'03ffu; // field_0 has id_spec any
constexpr auto identity_value = 0u;          // field_0 has id_spec any (zeroes are arbitrary)

// the write mask and the identity mask cover the range of the register,
// so the bus may OR the identity value with the written value to preserve
// unwritten bits and avoid read-modify-write
bus::template write<write_mask, identity_mask, identity_value>(/* ... */);