4

I'm investigating how I can implement a custom C++ HAL which targets multiple microcontrollers, potentially of differing architectures (ARM, AVR, PIC, etc) while keeping things sane.

I've inherited several large, messy code-bases which are un-maintainable in their current state hence the need for something more structured.

Having picked through a number of good articles and design guides, I'm considering a PIMPL implementation.

Consider the following example for a UART/serial port:

// -----------------------------
// High-level HAL
// -----------------------------

// serialport.h
class SerialPortPrivate;

class SerialPort {

public:
    SerialPort(uint8_t portNumber);
    ~SerialPort();

    bool open();
    void close();

    void setBaudRate(uint32_t baudRate = 115200);

private:
    SerialPortPrivate *_impl;
};   
// serialport_p.h
class SerialPort;

class SerialPortPrivate {

public:
    SerialPortPrivate(uint8_t portNumber, SerialPort *parent) {
        // Store the parent (q_ptr)
        _parent = parent;

        // Store the port number, this is used to access UART
        // specific registers UART->D[portNumber] = 0x10;
        _portNumber = portNumber;
    }
    ~SerialPortPrivate();

    bool open() = 0;
    void close() = 0;

    void setBaudRate(uint32_t baudRate) = 0;

protected:
    uint8_t _portNumber;

private:
    SerialPort *_parent;

};
// serialport.cpp
#include "serialport.h"
#include "serialport_p.h"    

#include "stm32serialport_p.h"
#include "avr32serialport_p.h"
#include "nrf52serialport_p.h"
#include "kinetisserialport_p.h"

SerialPort::SerialPort(uint8_t portNumber) {
#if MCU_STM32
    _impl = new Stm32SerialPortPrivate(portNumber, this);
#elif MCU_AVR32
    _impl = new Avr32SerialPortPrivate(portNumber, this);
#elif MCU_NRF52
    _impl = new Nrf52SerialPortPrivate(portNumber, this);
#elif MCU_KINETIS
    _impl = new KinetisSerialPortPrivate(portNumber, this);
#endif
}

void SerialPort::setBaudRate(uint32_t baudRate) {
    _impl->setBaudRate(baudRate);
}
// -----------------------------
// Low-level BSP
// Hardware-specific overrides
// -----------------------------

// stm32serialport_p.h
class Stm32SerialPortPrivate : public SerialPortPrivate {

};

// nrf52serialport_p.h
class Nrf52SerialPortPrivate : public SerialPortPrivate {

};

// kinetisserialport_p.h
class KinetisSerialPortPrivate : public SerialPortPrivate {

};    

The above code only has one set of #if/#endif statements within the constructor of the high-level interface (SerialPort) and the hardware-specific code (register accesses, etc) is done within the private implementation.

Taking the above further, I can see the above implementation working well for classes like I2cPort, SpiPort, UsbSerialPort but for other non-port related peripheral sets like clocks, hardware timers.

I'm sure there are some holes in the above concept, can anyone suggest from experience things to avoid or if there is a better way of abstracting?

weblar83
  • 681
  • 11
  • 32
  • aren't you reimplementing mbed-os ? and other multiple os-es that try to do just the thing you want to? – KamilCuk Mar 04 '19 at 12:22
  • @KamilCuk I have absolutely no idea. Am I? I hadn't considered going down the RTOS route for these projects but if I'm starting almost from scratch, maybe I should... – weblar83 Mar 04 '19 at 12:23
  • I vote this question is too broad. Ex. [mbed-os](https://os.mbed.com/platforms/) supports nrf52 stm32 kinetis. I think atmel is missing. And PIC (och my!) is missing. But you could take the abstraction they offer and implement it for the platforms. But there are several such projects to choose from, just search on github. The mbed-os is written in C++, so I mentioned it. It is really, really, really hard to integrate many hardwares, as each is unique. And there are many (big) projects, that tried and try to do the same thing as you do. – KamilCuk Mar 04 '19 at 12:33
  • #1 here is that C++ in itself is fairly non-portable between embedded systems. The quality of embedded C++ compilers remains mediocre at best. _If_ you were to use C++, then obviously the parent shouldn't manually call constructors of classes inheriting it, but rather the other way around. Also, using `new` doesn't make any sense, since this ain't for a PC. – Lundin Mar 04 '19 at 12:45
  • 1
    @Lundin, I must admit, I've never had an issue with any of the C++ compilers I've used for embedded systems. All code-bases I've inherited are in C++ and work fine, they're just a mess - back to C is a whole heap of other work that I'd not even though about (and I don't see why I really need to). I also didn't realise that the `new` keyword was strictly for PC-only! I'm working with devices which have 64/128/256kB of SRAM, not small 8k devices. – weblar83 Mar 04 '19 at 12:50
  • @weblar83 The amount of RAM doesn't matter. You don't have a scenario where different programs share the same big amount of RAM. You only have one program, bare metal or RTOS, that has exclusive access to all RAM available. You don't need to return allocated memory so that other programs can use it. In addition, you have fully deterministic use-cases. And therefore you don't need to have a heap. There's many other reasons why you don't want one, but the main reason is that it doesn't make sense in this kind of application. – Lundin Mar 04 '19 at 12:55
  • @Lundin We don't necessarily have fully deterministic use-cases - for example one of the projects allows the end user to turn on/off various external peripherals. These are heap-based objects which are created/destroyed on a need-by-need basis. We also have file accesses which use memory allocated on the heap, an LCD display which uses heap-allocated graphics for various screens of displays, etc, etc. We probably could move to stack-only but again, I don't really see a need to. – weblar83 Mar 04 '19 at 13:33
  • 2
    Of course you have deterministic use-cases. If there is 5 external peripherals then you have a specified maximum that the program should handle. It should not handle more and it needs to function when there are 5 enabled, so the necessary RAM needed to handle this is exactly 5, the worst case. It is _not_ in between 1 to 5, because you must always reserve space for 5. There is no point in only allocating room for 1 if the user requests 1, because there is nothing meaningful that your program can do with the memory for the other 4. It must remain reserved for the worst case. – Lundin Mar 04 '19 at 13:38
  • @Ludin We allocate space for 6 peripherals however, the end user is able to change the type of peripheral connected to each port via a UI which at present, ultimately results in a new type of `Device` class being instantiated for each. Also combined with this, we use 3rd party protocol stacks (such as nRF5, TCP/IP stacks, USB stacks, etc) which utilise the heap - I think making every application "stack only" would be backing ourselves into a corner. This is turning into a very valid conversation beyond my initial question though. – weblar83 Mar 05 '19 at 07:42

2 Answers2

8

Here are some of the concerns I'd have about your approach:

Firstly, let's say the the peripheral on one platform has some configuration option that simply doesn't exist for equivalent peripherals on other platforms. There are some options for how to handle this,e.g.:

  • Hardcode a particular value for that option
  • Include a file that provides the config values for that option, but don't provide the file with the hal. Each project that use the hal must provide this file as well.
  • Extend SerialPort to configure the option (extra function? some kind of callback?).

The first two are not very flexible (can't change at runtime) and the third breaks abstraction - platforms must provide functions to configure potentially non-existent options, or SerialPort users must know details of the underlying platform. All of these, in my opinion are ingredients for a messy codebase.

Secondly, let's say a platform has multiple different peripherals that can provide the same functionality. For instance, I'm currently working with an STM32 which has a USART and LPUART peripherals, both of which can provide UART functionality. To handle this, you'll need either to instantiate different pimpls at runtime depending on port, or have a single one for the platform that can handle. Doable, but can get messy.

Thirdly, to add support for another platform, you now need to modify a lot of other code to add new #elif clauses. Also the #if - #elif - #endif makes the code less readable, although good syntax highlighting will shade the inactive sections of code.

As for my advice:

Find the right interfaces. There's a temptation to try creating an interface for what the hardware can do - it's a hardware abstraction layer right?. However, I find it better to look at it from the interface clients' perspective - what are the use cases for the HAL. If you find a simple interface that can meet most or all of your use cases, it's probably a good one.

(This I think, is probably the most relevant to your point about clocks and hardware timers. Ask yourself: what are your use cases?)

A good example here is I2C. In my experience, most of the time a particular I2C peripheral is permanently a master or permanently a slave. I haven't often come across a need to swap at runtime between master and slave. With this in mind is it better to provide an I2CDriver that tries to encapsulate what a 'typical' I2C peripheral on any platform is capable of, or to provide a pair of interfaces I2CMasterDriver and I2CSlaveDriver, each of which provide only the use cases for one end of the I2C transactions.

I consider the latter the best starting point. The typical use case is either master or slave, and the use case is known at compile time.

Limit interfaces to what is 'universally common'. Some platforms may provide a single peripheral that does SPI/I2C, others provide separate peripherals. The same peripherals may, as mentioned above, have different configuration options between platforms.

Provide an abstract interface for the 'universally common' functionality.

Provide platform-specific implementations of that interface. These can also provide any required platform-specific configuration.

I think doing this - separating the 'universally common' and the hardware specific - keeps the interfaces smaller and simpler. That makes it easier to spot when it starts getting messy.

Here's an example of how I would go about this. First, define an abstract interface for the universally common functions.

/* hal/uart.h */
namespace hal
{
    struct Uart
    {
        virtual ~Uart() {};
        virtual void configure( baud_rate, framing_spec ) = 0;
        /* further universally common functions */
    };
}

Next, create implementations of this interface, which can include platform-specific details - configuration options, resource management. Configure your toolchain to only include these for the specific platform

/* hal/avr32/uart.h */
namespace hal::avr
{
    struct Uart : public hal::Uart
    {
        Uart( port_id );
        ~Uart();
        void configure( /*platform-specific options */ );
        virtual void configure( baud_rate, framing_spec );
        /* the rest of the pure virtual functions required by hal::Uart */
    };
}

For completeness, let's add a few higher-level 'clients' of the interface above. Note that they take the abstract interface by reference (could be pointer, but can't be by value as that'll slice the object). I've omitted namespaces and base classes here, as I think they illustrate better without.

/* elsewhere */
struct MaestroA5135Driver : public GPSDriver
{
    MaestroA5135Driver( hal::Uart& uart );
}
struct MicrochipRN4871Driver : public BluetoothDriver
{
    MicrochipRN4871Driver( hal::Uart& uart );
}
struct ContrivedPositionAdvertiser
{
     ContrivedPositionAdvertiser( GPSDriver& gps, BluetoothDriver& bluetooth );
}

Finally let's put it all together in a contrived example. Notice that the hardware -specific configuration is done especially, because the clients can't access it.

/* main.cpp */
void main()
{
    hal::avr::Uart gps_uart( Uart1 );
    gps_uart.configure(); /* do the hardware-specific config here */
    MaestroA5135Driver gps( gps_uart ); /* can do the generic UART config */

    hal::avr::Uart bluetooth_uart( Uart2 );
    bluetooth_uart.configure(); /* do the hardware-specific config here */
    MicrochipRN4871Driver bluetooth( bluetooth_uart ); /* can do the generic UART config */

    ContrivedPositionAdvertiser cpa( gps, bluetooth );
    for(;;)
    {
        /* do something */
    }
}

There are some drawbacks with this approach as well. For instance, passing instances to the constructor of higher-level classes can grow big fast. So all the instances need to be managed. But in general, I think the drawbacks are outweighed by the advantages - For instance, easy to add another platform, easy to unit test hal clients using test doubles.

Sigve Kolbeinson
  • 1,133
  • 1
  • 7
  • 16
  • Sigve, thank you very much for your comprehensive answer - this is absolutely the kind of advice I was hoping for. I like your example approach and like you say, it does away with the `#ifdef` requirements to add additional platforms, variants of MCU, etc. Using the namespaces from your example, I guess there is no reason why a `using namespace hal::avr` couldn't be added at the start of a source file to explicitly use the classes and structs for that architecture. I'll digest this answer further but thanks for taking the time to comment. – weblar83 Mar 06 '19 at 11:42
  • You're welcome - you're right about `using namespace`, although I would be careful where you expose the platform specifics. I limit it to two parts the code base. 1) The HAL itself (and only the platform specific implementation, not the generic interfaces), as the bottom architecture layer, and 2) the top layer containing the main function. (I label this layer 'runtime' in my architectures). In between, there are other layers, each of which can use abstract interfaces from lower layers - however only the runtime layer can instantiate the concrete instances. – Sigve Kolbeinson Mar 06 '19 at 12:35
0

To provide cross-platform interfaces, I like to use a "platform.h" file which keep all the #defines out of the source code, while also avoiding the code bloat that a big inheritance tree can generate. See this answer or this one for details.

In terms of what the interfaces actually are, I agree with @Sigve that looking at the use cases is the best design tool. Many low level peripheral interfaces may be reduced to init \ read\ write with just a few parameters exposed. Many higher level "HAL" tasks can often be decoupled from the hardware entirely and operate only a data stream.

AShelly
  • 34,686
  • 15
  • 91
  • 152