Posts in this series:

IRegisterTarget

IRegisterTarget is a class that provides an API for reading and writing registers in a device without having to worry about how that operation occurs, nor with piles of error handling boilerplate. Yes, that means errors are handled with exceptions, but it’s worth it.

First, let me present the class (without function bodies) and see if you can intuit what each function does.

template <ValidAddressOrDataType AddressType_, ValidAddressOrDataType DataType_>
struct IRegisterTarget
{
protected:
    IRegisterTarget(std::string_view name) : name(name) {}
public:
    using AddressType = AddressType_;
    using DataType = DataType_;
    virtual ~IRegisterTarget() = default;
    virtual std::string_view getName() const { return this->name; }
    virtual std::string_view getDomain() const { return "IRegisterTarget"; }

    virtual void write(AddressType addr, DataType data) = 0;
    [[nodiscard]] virtual DataType read(AddressType addr) = 0;

    virtual void readModifyWrite(AddressType addr, DataType new_data, DataType mask);

    virtual void seqWrite(AddressType start_addr, std::span<DataType const> data, size_t increment = sizeof(DataType));
    virtual void seqRead(AddressType start_addr, std::span<DataType> out_data, size_t increment = sizeof(DataType));

    virtual void fifoWrite(AddressType fifo_addr, std::span<DataType const> data);
    virtual void fifoRead(AddressType fifo_addr, std::span<DataType> out_data);

    virtual void compWrite(std::span<std::pair<AddressType, DataType> const> addr_data);
    virtual void compRead(std::span<AddressType const> const addresses, std::span<DataType> out_data);
};

Template Parameters

template <ValidAddressOrDataType AddressType_, ValidAddressOrDataType DataType_>

There are two template parameters, AddressType and DataType which represent the types used to hold address and data values, respectively. They are constrainted by the concept ValidAddressOrDataType which does nothing more than restrict the two types to be one of: uint8_t, uint16_t, uint32_t, uint64_t.

These types are used throughout the library to provide type-safe sizing of addresses and data. If a given device uses 16-bit addresses and 8-bit data accesses, it is represented by an IRegisterTarget<uint16_t, uint8_t> and reading or writing registers is a straightforward endeavour.

Logging / Identification

virtual std::string_view getName() const { return this->name; }
virtual std::string_view getDomain() const { return "IRegisterTarget"; }

IRegisterTarget provides hooks for identifying specific register targets in two different ways:

  • identifying the class, via getDomain()
  • identifying the specific instance, via getName() These are used in other parts of the library when logging operations so that the log reader can immediately know what kind of device is being targeted and some application-specific identifier of the device (presuming that there may be more than one instance of some devices).

These functions also interoperate with a logging library of mine, YALF, but YALF is not required to be used alongside RTF.

Reading and Writing Registers

virtual void write(AddressType addr, DataType data) = 0;
[[nodiscard]] virtual DataType read(AddressType addr) = 0;

It should be pretty obvious how to use these functions, but I want to point out that they’re both pure-virtual, meaning IRegisterTarget is an abstract base class. Applications must subclass IRegisterTarget and provide implementations for these two functions.

Generally, there will be separate subclasses for different kinds of devices in a system. On top of that, there may be separate subclasses if there are different ways to access said device. There will be examples of this later in the series, but for now just remember “if I have an IRegisterTarget& I can read/write registers on that device”.

Advanced Operations

The rest of the operations (member functions) defined for IRegisterTarget are virtual, but not pure. This is to provide naive/safe implementations of their functionality, but leave the option open for subclasses to provide more efficient implementations. An example of this will be provided later.

Read Modify Write

This function performs an operation anyone reading this is probably very familiar with.

virtual void readModifyWrite(AddressType addr, DataType new_data, DataType mask)
{
    DataType v = this->read(addr);
    v &= ~mask;
    v |= new_data & mask;
    this->write(addr, v);
}

As shown above, the default implementation simply reads the register, modifies the local copy of the data, and writes it back to the device. Note that this implementation is not atomic, so that should be handled in other ways within the application (most likely by simply only having one thread access a device at a time).

Sequential Read/Write

Sequential, or “seq”, reads/writes are reading or writing a series of sequential (in address space) registers in one operation:

virtual void seqWrite(AddressType start_addr, std::span<DataType const> data, size_t increment = sizeof(DataType))
{
    for (size_t i = 0 ; i < data.size() ; i++) {
        this->write(start_addr + (increment * i), data[i]);
    }
}
virtual void seqRead(AddressType start_addr, std::span<DataType> out_data, size_t increment = sizeof(DataType))
{
    for (size_t i = 0 ; i < out_data.size() ; i++) {
        out_data[i] = this->read(start_addr + (increment * i));
    }
}

Both of these take 3 arguments:

  • the first register address
  • a span of data to write (reading from the span) or read (writing to the span)
  • an increment, as in how much to increment the address between elements in the span The increment defaults to the size of the DataType as that is the most common way it is used, but it can logically support any other increment as well.

The default implementations of these, shown above, perform the straight forward operation you would expect, using the pure-virtual read() or write().

FIFO Read/Write

FIFO reads/writes are reading/writing from a single register repeatedly. In the case of writes, the hardware turns each write into a push of that data into a FIFO, and for reads, the hardware returns the latest value from the FIFO and advances it. Again, should be fairly common in most hardware devices.

virtual void fifoWrite(AddressType fifo_addr, std::span<DataType const> data)
{
    for (auto const d : data) {
        this->write(fifo_addr, d);
    }
}
virtual void fifoRead(AddressType fifo_addr, std::span<DataType> out_data)
{
    for (auto& d : out_data) {
        d = this->read(fifo_addr);
    }
}

The reader may now be wondering, “what’s the difference between FIFO writes and Sequential writes where the increment is zero?” The short answer is “logically, nothing really!” The longer answer is that it’s a semantic difference - FIFO writes/reads are a pretty specific operation in terms of Hardware, so presenting them as a different operation helps show intent. An even longer answer will get into overridden implementations of seqWrite()/seqRead() where it can only handle certain values of increment, but that will come later.

Compressed Read/Write

Compressed reads/writes are simply groups of reads/writes performed together.

virtual void compWrite(std::span<std::pair<AddressType, DataType> const> addr_data)
{
    for (auto const ad : addr_data) {
        this->write(ad.first, ad.second);
    }
}
virtual void compRead(std::span<AddressType const> const addresses, std::span<DataType> out_data)
{
    assert(addresses.size() == out_data.size());
    for (size_t i = 0 ; i < addresses.size() ; i++) {
        out_data[i] = this->read(addresses[i]);
    }
}

Compressed reads/writes don’t really make sense on their own like this - why woulnd’t the programmer simply use read() or write() repeatedly? The answer lies in situations where subclasses override these methods to provide more efficient accesses.

Finally, it’s time for…

Efficient Implementations

As stated before, subclasses may (but are not required to) provide overrides of readModifyWrite(), seqWrite(), seqRead(), and so on. Perhaps the communication protocol for the device provides these kinds of operations, so you could do a bulk fifo or sequential write in one packet. Or you could read a set of registers (via compRead()) in one command packet with all the data for those registers coming back in one response packet.

This is where it gets very application specific, but there are some things that have to be taken into account when writing these implementations.

Packet Size

Usually, these protocols have some kind of limit on how big the packet can be. This directly translates to how many registers can be written with seqWrite() or fifoWrite() for example.

All 6 of these functions will need to take this into account, but luckily, RTF provides a helper function: chunkify().

For example, lets say that we are implementing fifoWrite() but we must make sure that at most 16 values are written at a time:

virtual void fifoWrite(AddressType fifo_addr, std::span<DataType const> data) override
{
    static constexpr size_t MAX_FIFO_VALUES_PER_CHUNK = 16;
    RTF::chunkify(data, MAX_FIFO_VALUES_PER_CHUNK, [&](size_t idx, std::span<DataType const> chunk){
        // This callback will be called one or more times (specificaly `(data.size() + 15) / 16` times)
        // `idx` will tell you where in the overall span you are - here it would take on values: 0, 16, 32, 48, and so on.
        // `chunk` will be a subspan of `data` but will never be more than size 16 (though it could be less on the last iteration)
        ...
    });
}

Increment Limits

Another very common limitation applies to seqWrite() and seqRead() - most devices that implement this functionality cannot use any increment and are instead limited to a handful or possibly a small range.

For example, lets say this implementation can only handle increment values between 4 and 256 which are powers of 2:

virtual void seqWrite(AddressType start_addr, std::span<DataType const> data, size_t increment = sizeof(DataType)) override
{
    if (increment == 0) // Suppose we cant use the sequential mechanism on the device for this, but an increment of 0 is the same thing as a fifo write, so defer to that function instead!
        return this->fifoWrite(start_addr, data);
    
    if (increment < 4 || increment > 256) // Increment outside the range?  Let the base class default implementation handle it!
        return this->IRegisterTarget::seqWrite(start_addr, data, increment);

    if (((increment - 1) & increment) != 0) // Increment not a power of two?  Let the base class default implementation handle it!
        return this->IRegisterTarget::seqWrite(start_addr, data, increment);

    // At this point, we know `increment` satisfies the requirements, so perform the write!
    // Don't forget to use RTF::chunkify(), because that's probably also required here.
    ...
}

Next Steps

With just this part of RTF, you could start writing code that subclasses IRegisterTarget and code that uses it and be off to the races. But there’s so much more to RTF, so stay tuned!

Posts in this series: