Virtual Devices in Embedded Systems Software

The most common challenge we encounter on embedded software projects is the software’s dependency on the hardware. This dependency primarily manifests in embedded software projects in three ways:

  1. A belief that firmware development cannot begin in earnest until hardware is available
  2. The tendency to use the interfaces provided by an SDK throughout our programs
  3. Referencing specific instances and types for a specific driver throughout an application

The approach most often recommended for minimizing the impact of hardware changes is using hardware abstraction layers. However, in our experienceabstraction layers are often not used at all. If they are used, they are typically inherited from a vendor SDK or designed in an ad hoc manner. Both cases result in “leaky abstractions” that are actually tied to the underlying hardware in some way. This creates the potential for significant software rework whenever hardware details change. This defeats the purpose of adding the abstraction in the first place.

The authors of the paper we recommended in the previous post in the Practical Architecture series observed the same tendency in 1981:

Much of the complexity of embedded real-time software is associated with controlling special-purpose hardware devices. Many designers seek to reduce this complexity by isolating device characteristics in software device interface modules, thereby allowing most of the software to be programmed without knowledge of device details. While these device interface modules generally do make the rest of the software simpler, their interfaces are usually the result of an ad hoc design process, and they fail to encapsulate the device details completely. As a result, device changes lead to changes throughout the software, rather than just in the device interface module.

Virtual Devices

The authors of the paper outline a solution we can work toward: constraining hardware-specific (and by extension vendor-SDK-specific) information to a manageable set of modules. The majority of our program will use abstract interfaces to access hardware functionality. These interfaces are designed to support the software system’s needs. In the context of device drivers, our abstract interface can be mapped to a virtual device that satisfies the requirements of the software system.

To avoid these problems it is common to divide the software into two groups of components: 1) the device interface modules containing the device-dependent code, and 2) the device-independent remainder of the software, including the user programs, so called because they use the device interface modules. Device interface modules provide virtual devices, that is, device-like capabilities that are partially implemented in software.

The example they provide for a virtual device is a simple, yet illustrative one: instead of directly working with a driver for the particular altimeter currently installed in the system, the software interacts with a virtual altimeter which returns a range value that the software can work with.

For example, there is a virtual altimeter on the A-7 system. The virtual altimeter returns a value of type range, instead of the bit string read in from the actual sensor. The raw data is read, scaled, corrected, and flittered within the altimeter device interface module.

Translated to code, a virtual altimeter interface might look something like:

/** Virtual Barometric Altitude Sensor Interface
*
* Devices in the system which can produce barometric altitude readings must
* conform to this interface.
* 
* ## Fundamental Assumptions
* 
* - This device produces barometric altitude readings
* - This device will report barometric altitude in meters
* - The device will compute altitude on a configurable value for Sea Level
*   Pressure supplied by the system. If no value is provided, an SLP of 
*   29.921 will be assumed.
* - The device will be properly initialized by the system prior to any 
*   module invoking functions provided by this interface.
*/
class baroAltimeter {
	/** Get the current altitude
	*
	* @returns Current altitude in meters (m), corrected for 
	*   	sea level pressure (SLP).
	* 	If no value for SLP has been supplied, calculations will 
	*	assume 1013.25 millibars.
	*/
	float altitude() = 0;

	/** Set the sea level pressure
	*
	* @input slp Specify the sea level pressure (SLP) in millibars that is 
	*	used in range calculations.
	*/
	void seaLevelPressure(float slp) = 0;
};

As long as the implementations of altimeter drivers meet the requirements specified in the abstract interface, we can swap out implementations without changing the rest of the software. This improves our ability to quickly respond to changing operating conditions, reduces the lifetime maintenance cost of the total system, and enables us to make progress on embedded software projects ahead of hardware availability.

Defining Virtual Device Interfaces

The paper we recommended in the previous article provides a method that can be used to design abstract interfaces for device modules. If you haven’t read it yet, we recommend reading the paper before creating your own virtual device interfaces.

As a brief summary, the authors recommend approaching the problem from two angles:

  1. Create a list of assumptions that characterize the virtual device
    1. Carefully study devices in the given class (both currently available and under development)
    2. Make a list of common characteristics not likely to change if the device is replaced by another device of the same type
    3. Describe the assumptions that user programs are allowed to make about the virtual device. For example: “The device produces information from which barometric altitude can be determined.”
  2. Define the programming constructs that embody the assumptions
    1. Describe the public interfaces available to users of the interface
    2. Describe standard events and signals generated by device interface modules that implement the interface (e.g., a “sensor not operational” signal)

The authors recommend keeping the device assumptions and the interface definitions as two separate lists. For modern teams that tend to avoid documentation, keeping two separate lists can be a dealbreaker. We prefer to document the assumptions alongside the interface definition in code using Doxygen-style comments.

A basic example is provided with the altimeter class shown above. A more thoroughly documented example can be seen in the i2c.hpp and time_of_flight.hpp files in the embedded-resources repository.

The second step in the process of defining virtual device interface is critical: have your assumptions and programming constructs reviewed by a variety of people. You should target a mix of disciplines, including programmers, hardware designers, people who are familiar with the general class of device you’re creating an interface for, and people who are knowledgeable about the system’s design goals. Reviews should be iterative, as new, invalid, or missing assumptions may jump out as the review feedback is incorporated.

Note

Using Doxygen (or another documentation generation tool), we can generate documentation that can be more easily parsed by non-programmers who need to review our abstractions and assumptions.

One major mental roadblock in creating abstractions is the idea that we need to create abstractions that can handle all possible conditions. This just isn’t feasible. What we need to worry about is the options, interfaces, and requirements for our specific software system. If we try to create a perfect and universally usable abstraction layer, we will be stalled out.

Note

Keep this in mind as I share some abstractions I’ve used. What I describe is an abstraction that works on the systems that I’ve built. You may need (or prefer) different interfaces, options, or operational styles for your systems. Build the interfaces that your program needs!

Another common mental roadblock is the idea that we need to provide abstract interfaces that cover all capabilities provided by the device. We quickly run into problems here because devices differ widely in provided features, interfaces, configuration options, and power states. If we try to incorporate all of a device’s features into our abstractions, we will end up creating leaky abstractions that are tied to specific hardware in subtle ways.

Our virtual devices should only provide interfaces that represent the common functionalities required by the software application. The concrete driver implementations themselves can provide as many additional interfaces and configuration options as you need. You can use this functionality safely in a module that isn’t hardware-independent, such as the place you configure the hardware for your application.

Further reading

For more information on handling device categories with a wide range of capabilities, refer to “A Procedure for Designing Abstract Interfaces for Device Interface Modules”.

Let’s take a look at some example abstractions that we’ve used in Embedded Artistry projects.

Example Abstractions

Now we’re going to look at two driver abstractions:

  • I2C Controller (aka Master)
  • Time-of-Flight (ToF) sensor

The examples in this article use C++ virtual classes and inheritance for simplicity in communicating and understanding the concepts. The core idea of this pattern can be translated to structs of function pointers, CRTP, concepts, traits, standalone libraries using a common interface, or other suitable approaches.

I2C Driver

I2C Controllers tend to be quite standard in their behavior across implementations. Because of that, we can develop a comprehensive virtual device interface.

Here are common I2C bus characteristics that we can encapsulate inside of our virtual device:

  • Addresses are 7-bits nominally. The address is shifted by 1 bit, and an LSB of 1 is used to indicate a read operation
  • Busses are generally in one of three states:
    • Idle
    • Busy
    • Error
  • We can detect a common set of error conditions:
    • Busy
    • Bus error
    • Address was transmitted, but a NACK was received
    • Data was transmitted, but a NACK was received
  • A finite number of baud rates are supported:
    • 10 kbps (low speed)
    • 100 kbps (standard)
    • 400 kbps (fast)
  • We can perform a variety of operations:
    • Generate a stop condition
    • Generate a bus restart
    • Write to a device
    • Read from a device
    • Write from a device and then read from a device
    • Perform a write without issuing a stop condition
    • Continue a write without issuing a stop condition
    • Continue a write and issue a stop condition
    • Ping a device to see if there is an ACK
  • A bus transfer operation can be defined using the following parameters:
    • An address to talk to
    • The type of operation to perform
    • A buffer of data to transmit to the device (optional for reads)
    • The size of data to transmit to the device (optional for reads)
    • A buffer of data to hold data received from the device (optional for writes)
    • The size of data to read from the device (optional for writes)

The exact data types and values for each of these characteristics varies from one system to another. Our virtual I2C controller will define its own types that map to these characteristics, enabling our hardware-independent modules to use a common set of types. The concrete driver implementations will map these virtual types onto the actual types and values expected by the controller hardware or SDK.

enum class i2c::baud
{
	lowSpeed = 10000,
	standard = 100000,
	fast = 400000
};

enum class i2c::operation : uint8_t
{
	stop = 0, /// Generate a stop condition
	restart, /// Generate a bus restart

	/// Write to slave
	/// Performs the following sequence: 
	/// start - address - write - stop
	write,

	/// Read from slave
	/// Performs the following sequence: 
	/// start - address - read - stop
	read,

	/// write to slave and then read from slave
	/// Performs the following sequence: 
	/// start - address - write - restart - address - read -
	/// stop
	writeRead,

	/// start a write to the slave and but do not issue a stop
	/// Performs the following sequence: 
	/// start - address - write
	writeNoStop,

	/// continue a write to the slave and do not stop the transaction
	/// Performs the following sequence: 
	/// write
	continueWriteNoStop,

	/// continue a write to the slave and then stop the transaction
	/// Performs the following sequence: 
	/// write - stop
	continueWriteStop,

	/// Send only address to see if there is an ACK
	/// Performs the following sequence: 
	/// start - address
	ping,
};

/** I2C operation definition.
 *
 * The I2C bus operates in half-duplex mode, meaning that 
 * reads and writes do not occur simultaneously. The operation
 * type represents a variety of operations which can be performed 
 * by an I2C device. The transmit options (tx_buffer and tx_size) 
 * are required by any operations using "write". The receive options 
 * (rx_buffer and rx_size) are required by any operations using "read".
 */
struct i2c::op_t
{
	/// Slave device address (7-bit).
	uint8_t address{0};

	/// Operation to perform.
	operation op{operation::stop};

	/// Pointer to the transmit buffer.
	/// Does not need to be specified for read-only commands.
	const uint8_t* tx_buffer{nullptr};

	/// Number of bytes to transmit.
	/// Does not need to be specified for read-only commands.
	size_t tx_size{0};

	/// Pointer to the receive buffer.
	/// Does not need to be specified for write-only commands.
	uint8_t* rx_buffer{nullptr};

	/// Number of bytes to receive.
	/// Does not need to be specified for write-only commands.
	size_t rx_size{0};
};

We can also define a common set of expected operations:

  • start the controller
  • stop the controller
  • Set the baud rate
  • Read the current baud rate
  • Perform a transfer
  • Perform a bus sweep
Note

What isn’t included here? Notably, processor-specific implementation details such as interrupt configuration and DMA configuration. These types of operations are included in the concrete driver, but in most cases hardware-independent modules don’t have any real need for this information.

The assumptions and expectations of these functions can vary widely depending on the requirements of the system. It’s important to explicitly document the assumptions so users and implementors understand the system’s expectations. For example, start and stop can be vague concepts. We can document the assumption that start() will power on the controller and configure it, while stop() will power off the controller if possible.

/// Put the driver in an operational state
/// (fully configured)
virtual void start() noexcept = 0;

/// Put the driver in a non-operational state 
/// (powered off if possible).
virtual void stop() noexcept = 0;

Other operational assumptions are important to document as well. For instance, the system might be designed around asynchronous operations. This assumption must be documented in the interface, because we cannot easily change from asynchronous to synchronous operations without a program redesign.

/** Initiate a bus transfer.
 *
 * Initiates a transfer of information across a comm bus. 
 * The transfer function should handle read, write, and read-write 
 * operations. Other operations may also be supported by derived
 * classes.
 *
 * The transfer request is forwarded the derived class, which must 
 * implement the pure virtual function transfer_().
 *
 * This call should not block. Transfer requests are asynchronous 
 * and are not guaranteed to return immediately with a result.
 *
 * If the derived driver class returns a status code other than 
 * i2c::status::enqueued or i2c::status::busy, the callback will be 
 * immediately invoked. If the operation was enqueued, the
 * final status will be reported when the callback is called.
 *
 * @param op A bus transfer is defined by an operation (op). 
 * 	The derived class will use the data supplied in the op to 
 * 	configure the bus and transfer the data.
 * @param cb Optional callback parameter. When set, the callback 
 * will be invoked upon completion of the transfer.
 * @returns The status of the bus transfer.
 */
virtual i2c::status transfer(i2c::op_t& op, const cb_t& cb = nullptr) noexcept
{
	auto status = transfer_(op, cb);

	if(status != i2c::status::enqueued && status != i2c::status::busy)
	{
		callback(op, status, cb);
	}

	return status;
}

/** The derived class's transfer implementation.
 *
 * Derived classes override this transfer function to handle
 * specific transfer operations for each device.
 *
 * Callback is passed in for drivers which enqueue operations 
 * or use AO model. The base class handles the callback, so 
 * there is no need to worry about invoking the callback from
 *  a client driver.
 * 
 * This function must be asynchronous and not block.
 *
 * Just mark your callback `(void)callback` if you aren't using it.
 *
 * To indicate that a transfer has been enqueued for later processing,
 * return  `i2c::status::enqueued`.
 */
virtual i2c::status transfer_(const i2c::op_t& op, const cb_t& cb) 
	noexcept = 0;

We may also want to note the validity assumptions for memory that is passed into our virtual interfaces, especially for asynchronous operations:

/** Perform an I2C bus sweep to identify active devices.
 *
 * The sweep function pings all I2C addresses. Devices which ACK 
 * are stored in a list and returned via callback.
 *
 * @param[in,out] found_list Caller's memory which will contain the 
 *	successfully found ping addresses.
 * @param[in] cb The callback which will be called to indicate that 
 *	the sweep is complete. When the cb is called, found_list is valid 
 *	and can be used by the caller.
 */
void sweep(sweep_list_t& found_list, const sweep_cb_t& cb) noexcept;

Of course, our system’s needs may change the interfaces. For instance, you may want to provide an API for configuring internal pull-ups. Or you might prefer to leave it out, expecting this to be handled during the hardware configuration stage. You might have devices in your system which need to talk using different baud rates, which makes the baud rate function a necessity. However, you may also expect that all devices in the system operate at 100 kbps, making the baud rate interface unnecessary.

Further reading

Full source code can be found on GitHub.

Time-of-Flight Sensor

Sensors, especially ToF sensors, tend to vary widely in their behavior, configurability, precision, and operational conditions. Because of this variability, we are reduced to a much smaller set of generic interfaces. Device-specific configuration (e.g., data rates, operational mode, hardware FIFO depth) is handled in hardware-dependent modules.

Generic operations for a ToF sensor might include:

  • start the device
  • stop the device
  • reset the device
  • Get the current mode (default, short range, medium range, long range)
  • Set the current mode (default, short range, medium range, long range)
  • read the current distance in mm

As with the I2C device, it’s important to note down the assumptions and implementation requirements for the various interfaces. For instance, we want to document the assumption that start() will power on the sensor and fully configure it, while stop() will power down the sensor or place it into the lowest power state whenever possible.

/// Turn on the device, put it into a fully operational state, 
/// and apply necessary configuration options.
virtual void start() noexcept = 0;

/// Put the sensor into a non-operational state.
/// If possible, the device should be powered down
/// or put into the lowest power state.
virtual void stop() noexcept = 0;

As before, we also need to document operational assumptions such as the fact that read will be asynchronous instead of blocking.

/** Trigger a sensor read.
 *
 * Trigger an asynchronous read of the ToF sensor. The result 
 * will be provided to consumers through a callback function. 
 * When the read() operation completes, the callback will be 
 * invoked with the result.
 *
 * The sensor result will be presented as current range in mm.
 */
virtual void read() noexcept = 0;

Our asynchronous operation also necessitates the ability to register a callback that will be invoked when read() completes. This function would not be necessary if read() was a blocking instead of asynchronous.

/** Register a callback for the read() function.
 *
 * The read() function works asynchronously, and the result 
 * will be provided to consumers through a callback function. 
 * When the read() operation completes, the callback will be 
 * invoked with the resulting range (in mm).
 *
 * This function must be implemented by the derived class.
 *
 * @param cb The functor which will be called when read() completes.
 */
virtual void registerReadCallback(const cb_t& cb) noexcept = 0;

Since sensor information tends to vary widely, we might decide that our hardware-independent modules need some information about the sensor in order to make appropriate decisions. For instance, the range of a ToF sensor is different if you’re in the dark or if you’re in strong light. If this kind of information is important to the system, you can provide access to it through virtual device interfaces.

/** Check the maximum range in the dark.
 *
 * @returns the sensor's maximum distance capability 
 * 	(in mm) in the dark.
 */
virtual tof::distance_t getMaxRangeForModeDark(tof::mode m) 
	const noexcept = 0;

/** Check the maximum range in strong light conditions.
 *
 * @returns the sensor's maximum distance capability 
 * 	(in mm) in strong light.
 */
virtual tof::distance_t getMaxRangeForModeStrongLight(tof::mode m) 
	const noexcept = 0;
Note

The assumptions here could be improved by noting what “dark” and “strong light” mean in terms of lumens.

Remember, this is an example virtual device interface. Your specific application may require more or less features depending on the system’s needs. For instance, it is completely reasonable for a system to have strong requirements on the range for ToF measurements, which limits the number of sensors that can work in the system. The range assumptions can be documented in the virtual device, as well as the assumption that proper mode configuration is handled by the hardware configuration module. In this case, all mode-related interfaces can be removed. The virtual device only needs to handle power states, reading samples, and registering callbacks.

Further reading

Full source code can be found on GitHub.

Implementing Drivers using Virtual Device Interfaces

Our virtual devices merely define the standard interface and behaviors expected by hardware-independent modules in our system. The actual concrete driver implementations are not purely constrained to these interfaces and assumptions. We can still provide functions that support device configuration and features not covered by the abstract interface.

Some drivers, such as our Aardvark adapter’s I2C driver, may be written to map completely onto the virtual device. Other drivers, such as our nRF52 driver, may provide additional functions that are not included in the abstract interface, such as enableInterrupts() and disableInterrupts. Additional configuration options are also used, including GPIO configuration information for the SCL and SDA pins and the desired interrupt priority. These hardware-dependent details are still used in our system, but they are kept hidden from the hardware-independent modules.

Note

We constrain this type of hardware-dependent configuration information in the “Hardware Platform”, which will be covered in a future article.

Our VL53L1X ToF Sensor driver is another example of a driver that provides additional interfaces beyond what is expected by the virtual device interface. These interfaces might be used in other hardware-dependent modules to ensure that this particular part behaves in a way expected by the system.

The primary reason I included the VL53L1X sensor driver as an example is because it uses a virtual I2C controller.

explicit vl53l1x(embvm::i2c::controller& i2c, 
    uint8_t address = VL53L1X_DEFAULT_I2C_ADDR) noexcept
  : embvm::tof::sensor("ST VL53L1X ToF"), i2c_(i2c), address_(address)

The driver has no knowledge of the specific I2C controller that it’s connected to. It only knows about the interfaces and types provided by the virtual controller.

void vl53l1x::readReg(const uint16_t* reg_buf, 
	uint8_t* rx_buffer, size_t rx_size,
	const embvm::i2c::controller::cb_t& cb) noexcept
{
	embvm::i2c::op_t t;
	t.op = embvm::i2c::operation::writeRead;
	t.address = address_;
	t.tx_size = sizeof(uint16_t);
	t.tx_buffer = reinterpret_cast<const uint8_t*>(reg_buf);
	t.rx_size = rx_size;
	t.rx_buffer = rx_buffer;

	i2c_.transfer(t, cb);
}

By using a virtual I2C controller interface, our VL53L1X driver can be used with any I2C controller that satisfies the expected interfaces and assumptions. We often use this approach to develop device drivers on our development machine using an Aardvark I2C adapter. When the driver is working as expected, we can then port it to the target embedded platform without any modifications.

Note

Even though the VL53L1X driver’s implementation only uses a virtual I2C controller, we still have to hook up the specific ToF sensor driver instance to a specific I2C controller instance. This will be demonstrated in the next article and when we cover the Hardware Platform.

Using Existing Drivers with Virtual Device Interfaces

You don’t need to write custom drivers from scratch that match the expectations of the virtual device interfaces. You can still use existing drivers or drivers provided by a vendor SDK.

Instead of writing the driver from scratch, create a wrapper or Adapter that is used to translate between the two interfaces. Your Adapter will provide an interface that matches the virtual device interface. It will translate the provided information as necessary and forward it to the existing driver or vendor SDK functions. The Adapter may also need to handle additional internal operations to ensure that the assumptions expected of the virtual device are appropriately satisfied. The most common example is ensuring that the output data is presented in the format specified by the virtual device.

Other Known Uses of the Pattern

We use this approach in our Embedded Virtual Machine project. We interact with drivers in application code using virtual interfaces instead of talking directly to a specific instance.

The modm project defines standard interfaces for a variety of device types. The modm build system is capable of generating implementations of these interfaces for a large number of processors. Developers can also build their own drivers using the standard interfaces. Modules that use the modm interfaces can be easily ported from one processor to another without modification.

Another example is the Rust embedded_hal project. This project provides a set of generic device APIs for processor peripheral hardware. The APIs cover a variety of peripheral types, such as analog-to-digital converters (ADCs), digital I/O, pulse width modulation (PWM), timers, watchdog timers, and SPI. Developers can create platform-agnostic drivers in Rust by building them on top of the embedded_hal APIs. Since this HAL is language specific, it provides much more portability than the typical vendor SDK HALs used in many embedded software systems. To support a new processor, the embedded_hal APIs simply need to be implemented for the new processor. Drivers built on top of embedded_hal will not need to be modified at all.

Putting it All Together

Examples for this article are provided in the embedded-resources repository and were taken as a snapshot from the Embedded Virtual Machine project.

You can see the example virtual device interfaces as follows:

Drivers which implement these virtual device interfaces are also provided:

  • Aardvark I2C Adapter (.cpp.hpp)
  • ST VL53L1X Time-of-Flight Sensor (.cpp.hpp)
  • nRF52 I2C (.hpp)

The Aardvark and VL53L1X modules currently compile. The nRF52 files are provided for reference.

In the next article in the Practical Architecture series, we will create runnable examples to demonstrate driver portability. We will show how the same time of flight sensor driver can run on both OS X (using an Aardvark I2C Adapter) and an nRF52 without modification.

Following that article, we will explore methods for accessing virtual devices from hardware-independent modules without exposing unnecessary hardware details.

Further Reading

Embedded Artistry members can download this article in .mobi and .epub formats. If you’re a member, log in.

Share Your Thoughts

This site uses Akismet to reduce spam. Learn how your comment data is processed.