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
This repository uses CPM and a common CI/CD infrastructure.
The library dependencies are:
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:
C | W | R |
---|---|---|
0 |
0 |
0 |
0 |
1 |
1 |
1 |
0 |
0 |
1 |
1 |
1 |
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>(/* ... */);