Exploring Embedded System Architecture: Programming Design Patterns (Part 1) – Design Patterns for Accessing Hardware

This series begins with a discussion on software design, particularly focusing on design patterns in object-oriented programming, which you may have encountered in your own development or seen in others’ code. When your codebase becomes sufficiently large, you may find maintenance becomes a daunting task, where a small change can have wide-reaching consequences. At this point, you can appreciate the importance of establishing a solid architecture during the early stages of development. One of the most fundamental concepts in architecture is design patterns. The use of design patterns aims to promote code reusability, enhance code understandability for others, ensure code reliability, and facilitate program reusability. By studying excellent open-source code, you will be amazed at how others manage their programs, leading you to gradually understand the purpose of architecture.

This article is based on the book “Design Patterns for Embedded Systems in C”. It serves as a note-taking exercise, supplemented with some personal insights, intended for sharing and discussion with all of you. The focus is primarily on embedded systems and microcontrollers, with programming primarily in C language. Although it is process-oriented, it does not prevent us from employing object-oriented thinking in development.

1. Design Patterns for Accessing Hardware

Embedded systems, especially microcontrollers, are characterized by direct access to hardware. Basic hardware includes devices such as CPUs, memory, keyboards, sensors, and communication interfaces like RS232. Those working with microcontrollers inevitably need to control hardware through read and write operations, and this article addresses a common pattern for managing and operating these hardware components. You may be familiar with it, but articulating it systematically and in detail is not something that can be achieved through mere understanding.

The design patterns discussed below have been proven reliable and effective in hardware operations. In summary, the hardware proxy pattern is a prototype pattern for hardware abstraction aimed at encapsulating details, which can alter how information is processed when provided to or received from hardware. The hardware adapter pattern extends the hardware proxy pattern, providing the capability to support different hardware interfaces. The mediator pattern facilitates coordination among multiple hardware devices, achieving system-level behavior. The observer pattern is a method for publishing remote sensing data to the required software elements. The debounce pattern and interrupt pattern are simple methods for reusing hardware device interfaces. The timer pattern extends interrupt timers to provide precise timing for embedded systems.

1.1 Hardware Proxy Pattern

The concept of the hardware proxy pattern is to encapsulate access to hardware interfaces, limiting clients from directly accessing hardware, which could lead to issues.

1.1.1 Pattern Structure

Exploring Embedded System Architecture: Programming Design Patterns (Part 1) - Design Patterns for Accessing Hardware

The pattern structure is quite simple; there may be multiple clients, but each hardware device has only one hardware proxy. Clients can only access the proxy interface and cannot directly access the hardware, which is the purpose of this pattern.

1.1.2 Roles

1.1.2.1 Hardware Device (HardwareDevice)

Hardware devices can vary, including memory, sensors, etc., and contain elements such as port addresses, memory addresses, register addresses, etc. The association with the hardware proxy is accomplished through software addressing for read and write operations on the hardware.

1.1.2.2 Hardware Proxy (HardwareProxy)

This is the main functionality within the system. It provides the hardware access interface for the upper-level applications, which do not need to concern themselves with the specific implementations of the hardware. Typically, each proxy has initialize(), configure(), and disable() functions. Most will also have access interfaces for reading or writing device values. However, random read and write operations are generally not allowed; the reading will be detailed to return the final value.

Functions include:

access(): Returns a special value from the device. In most cases, the proxy provides a separate function for each piece of information coming from the device, such as returning temperature and humidity values from a sensor.

configure(): Provides a method for hardware configuration. Generally, there will be a list of parameters to configure the correct operational state.

disable(), enable(): Provide methods for safely disabling or enabling the device.

initialize(): Used for initializing hardware upon first startup.

mutate(): Used for writing data to the device, usually with one or more input parameters.

marshal(), unmarshal(): These two are private functions used to convert client data formats to the formats required by the hardware and vice versa. They are commonly used for encryption/decryption, compression/decompression, etc.

deviceAddr: A private variable providing direct access to the hardware address. It must be hidden within the proxy to prevent client access, so special attention must be paid to certain interfaces to ensure this variable is not exposed through pointers.

1.1.2.3 Proxy Client (ProxyClient)

Client code calls hardware proxy services to access hardware devices.

1.1.3 Effects

This pattern is very common and encapsulates hardware interfaces while providing all the advantages of coding systems. It offers flexibility in fundamentally changing the actual hardware interface without requiring any changes to the client. Essentially, all hardware devices can be constructed using this pattern, provided that details are not exposed, only returning a final result, especially in read/write operations; otherwise, encapsulation is lost.

1.1.4 Implementation

There are many different methods to implement this in C language, with the most common being Linux drivers that use function pointers within structures to unify hardware interfaces, which are then implemented on specific hardware devices.

1.2 Adapter Pattern

The hardware adapter pattern provides a method to make existing hardware interfaces suitable for application expectations. It can be said that it adds an adapter layer in between to accommodate different underlying hardware devices based on the hardware proxy pattern. For example, in communication, hardware may support RS232 and RS485, and the program needs to use either 232 or 485 communication under different circumstances. The adapter can provide a unified interface to the client layer, pointing to the required communication, thus achieving this. The biggest feature is the ability to select at runtime, unlike using macro definitions that require generating different executable programs; it can implement adaptive functionality within the program.

1.2.1 Pattern Structure

Exploring Embedded System Architecture: Programming Design Patterns (Part 1) - Design Patterns for Accessing Hardware

1.2.2 Roles

1.2.2.1 Hardware Adapter (HardwareAdapter)

The hardware adapter acts as a matchmaker between the client and the hardware proxy. The client informs the adapter of the required hardware device, and the adapter executes the client’s request.

1.2.2.2 Client Hardware Interface (HardwareInterfaceToClient)

The client’s hardware interface represents the set of services and parameter lists that the client expects the hardware proxy to provide. It serves merely as an interface and does not implement anything; hardware implementation is provided through the adapter.

1.2.2.3 Hardware Device (HardwareDevice)

Described consistently with the hardware proxy pattern.

1.2.2.4 Hardware Proxy (HardwareProxy)

Described consistently with the hardware proxy pattern.

1.2.3 Effects

This pattern allows for the use of various hardware proxies and the ability to use associated hardware devices in different applications, while also enabling applications to use different hardware devices without requiring changes. I personally understand this as somewhat akin to the concept of polymorphism in object-oriented languages.

1.2.4 Implementation

Similarly, in Linux system drivers, an interface proxy structure is created, and hardware devices implement these interfaces specifically. A pointer is used to point to the structure interface, allowing the required hardware device to be registered to the pointer, and client code only needs to call this pointer to operate the specific hardware device, while also allowing for dynamic modification of the pointer’s direction, thus enabling dynamic loading and switching.

1.3 Mediator Pattern

The mediator pattern provides a method for coordinating complex interactions among a group of hardware devices.

1.3.1 Pattern Structure

Exploring Embedded System Architecture: Programming Design Patterns (Part 1) - Design Patterns for Accessing Hardware

The mediator pattern uses a mediator class to coordinate the behavior of a collection of devices to achieve an organized effect. For instance, consider a car with four wheels, each with its own motor; when moving forward, all four wheels move forward simultaneously. In this case, the mediator assumes the responsibility of controlling all four wheels. Thus, the mediator functions as a central controller.

1.3.2 Roles

1.3.2.1 Collaborator Interface (CollaboratorInterface)

This is the interface called by the mediator, typically consisting of functions such as initialize(), enable(), reset(), etc., but the specifics are implemented in the actual collaborators.

1.3.2.2 Mediator (Mediator)

The mediator coordinates all specific collaborators in the pattern. The mediator maintains a link to each specific collaborator, allowing it to send messages to them. Additionally, when events occur, each specific collaborator must be able to send messages to the mediator. The mediator provides the coordination logic.

1.3.2.3 Specific Collaborator (SpecificCollaborator)

This represents a hardware device, capable of receiving commands from the mediator and sending information back to it.

1.3.3 Effects

This pattern creates a mediator to coordinate specific hardware collaboration, but clients do not need to couple directly with hardware devices, greatly simplifying the design organization. Many embedded systems require high-precision timing responses, and delays in actions can lead to unpredictable consequences; thus, the mediator’s ability to respond within these specified times is crucial.

1.3.4 Implementation

The mediator can be implemented through pointer arrays, linked lists, etc., which can connect to each specific collaborator. Additionally, a unified interface can bring many conveniences to the mediator’s code.

1.4 Observer Pattern

The observer pattern is very common; you can see its presence everywhere. This pattern provides a method to “listen” for messages of interest without requiring modifications to the data server, meaning sensor data can be easily shared with the required clients.

1.4.1 Pattern Structure

Exploring Embedded System Architecture: Programming Design Patterns (Part 1) - Design Patterns for Accessing Hardware

The observer pattern, also known as the “publish-subscribe pattern”, operates under the principle that the data server does not need to know the clients. Instead, it is the clients that notify the data server, which is referred to as subscribing. Subscribing means allowing the data server to add (and remove) itself from the notification list. The most common notification strategy is for the server to send data when new data arrives, although clients can also periodically update and request data from the server to reduce the server’s computational burden and ensure clients have real-time data. A more complex approach is to add a central controller between the data server and clients for communication, allowing the server to avoid direct contact with clients. If messages are heavily used, employing an observer pattern with a central controller can be a good method.

1.4.2 Roles

1.4.2.1 Abstract Client Interface (AbstractClient)

It contains the accept(Datum) function, which is called by the AbstractClient when it subscribes or when the AbstractSubject deems it appropriate to send data.

The AbstractClient is abstract and does not provide any specific implementation.

1.4.2.2 Abstract Subject Interface (AbstractSubject)

In the pattern, the AbstractSubject serves as the data server. It provides three functions related to the pattern: subscribe(acceptPtr) adds a pointer to the notification list of receiving functions, unsubscribe(acceptPtr) removes the receiving function from the notification list, and notify() traverses the notification list to notify subscribed clients.

1.4.2.3 Concrete Client (ConcreteClient)

The ConcreteClient is a specific implementation of the AbstractClient interface.

1.4.2.4 Concrete Subject (ConcreteSubject)

The ConcreteSubject is a specific implementation of the AbstractSubject interface. It not only implements the functions but also provides methods for obtaining and managing the data it publishes. The ConcreteSubject can also represent hardware devices, sensors, etc.

1.4.2.5 Data (Datum)

This element is the actual data packet, which can be an int or, more commonly, a complex structure.

1.4.2.6 Callback Interface (NotificationHandle)

The NotificationHandle represents the call to the client’s accept method. The most common implementation is via function pointers.

1.4.3 Effects

The observer pattern manages the process of distributing data from the server and can dynamically manage the client list at runtime. For example, reading hardware values is often done through polling, but polling has the drawback of being unresponsive, and the interval for reading is difficult to fix and evaluate. Another method is to read at regular intervals, but this may not always yield data. Furthermore, interrupt-triggered methods can be problematic if calculations are needed after reading data during an interrupt; the principle is to minimize CPU usage during interrupts. The advantages of the observer pattern become evident here; it ensures timely responses using callbacks, allows one hardware device to publish data to multiple receiving clients, and guarantees that each client callback execution yields data. In fact, the observer pattern is ubiquitous, as seen in the communication of nodes in the ROS system. The pattern’s obvious drawback is its complexity of implementation, and it may not suit all situations, so careful analysis is recommended to select the appropriate method.

1.4.4 Implementation

The complexity of this pattern lies in the implementation of notification handles and managing the list of notification handles. Notification handles are typically callback function pointers. The simplest way to manage the notification list is to define a sufficiently large array to contain all potential users, but this is generally not common due to excessive space usage. A more common approach is to manage it using linked lists, where each notification handle is added to the list, allowing for easy traversal to notify all clients; this method is strongly recommended.

1.5 Debounce Pattern

This pattern is used to eliminate multiple false times caused by intermittent connections from the metal surfaces of hardware.

1.5.1 Pattern Structure

Exploring Embedded System Architecture: Programming Design Patterns (Part 1) - Design Patterns for Accessing Hardware

The solution is to accept the first occurrence of the event, wait for the jitter to subside, and then read its state.

1.5.2 Roles

1.5.2.1 Application Client (ApplicationClient)

This element is the final recipient of the debounce process. After eliminating the jitter, it uses deviceEventReceive() to receive the last read value.

1.5.2.2 Concrete Hardware (BouncingDevice)

This represents the hardware device. Most of these devices are entirely hardware-based, so they exhibit jitter phenomena. sendEvent() is used to send events and activate interrupts to receive the first response. getState() operates by reading memory or I/O ports to display the actual hardware value. deviceState is typically a binary attribute, either ON or OFF.

1.5.2.3 Hardware Client (DeviceClient)

This is used to handle incoming events, debounce them, and read to ensure they reflect the actual device state. Its eventReceive() function is activated via the BouncingDevice’s sendEvent() function. Additionally, it needs to set a delay timer, so that after the debounce event, if the state matches the first read, it proves the value is genuine. Thus, it sends the corresponding information to the ApplicationClient. The old state is stored in the variable oldState, which updates whenever the state changes.

1.5.2.4 Timer (DebouncingTimer)

This timer can provide idle waiting through the delay() service. It can use while() waiting or implement a hardware timer.

1.5.3 Effects

Typically, the task of debouncing is handled by software. This is a simple debounce, where the application only needs to care about the true value generated by the hardware state.

1.5.4 Implementation

The hardware client typically uses interrupts to notify the application client. Alternatively, mixing with the observer pattern can also signal various clients. In RTOS systems, attention must be paid to the timing units for delay times; for instance, if a 45-millisecond delay is desired, the closest time precision greater than or equal to the expected time must be used. If you do not mind fully occupying the CPU while waiting to debounce, it can be straightforwardly achieved with a while(loop–) loop.

1.6 Interrupt Pattern

In embedded systems, hardware devices often generate events autonomously, and if not monitored, these events can be lost. When an event of interest occurs, using interrupts for notification is a very effective method. Most chips support external hardware interrupts. Interrupts ensure timely responses, but they can preempt CPU control, so time-consuming tasks such as algorithms are not suited for handling within interrupts. This pattern can be a purely software-based interrupt pattern.

1.6.1 Pattern Structure

Exploring Embedded System Architecture: Programming Design Patterns (Part 1) - Design Patterns for Accessing Hardware

It is ensured that interrupt functions generally have no parameters and no return values.

1.6.2 Roles

1.6.2.1 Interrupt Response (InterruptHandler)

This is the only element within the interrupt pattern with specific behaviors. It can install and uninstall interrupt vectors. The install() function copies the provided interrupt handler into the vector table, using the appropriate interrupt service program address. The deinstall() function does the opposite, unloading and restoring the original vector table.

Each handleInterrupt_x() function processes the specified interrupt.

1.6.2.2 Interrupt Vector Table (InterruptVectorTable)

This is an array of addresses for interrupt service programs, residing at a specified memory location. When interrupt number x occurs, the CPU suspends the current process and invokes the address at the xth index in this array.

1.6.2.3 Vector Pointer (VectorPtr)

VectorPtr is a data type, specifically a function pointer with no parameters and no return values.

1.6.3 Effects

The greatest advantage of this pattern is its high responsiveness to events of interest. Typically, when an interrupt service program is executed, interrupts are disabled, meaning the interrupt service program must execute quickly to avoid losing other interrupts.

Special attention must be paid to resource protection when both interrupts and regular programs handle the same element. For instance, if a regular program is reading data and an interrupt occurs, the interrupt could pause the regular program and modify the data being returned. The regular function would read corrupted data, which includes both new and old data. Solutions include: 1. Disabling interrupts while the regular function reads data and restoring them afterward. 2. Using mutex semaphores.

1.6.4 Implementation

Before executing the interrupt function, the context must be saved, and it must be restored after execution. In fact, each interrupt service program must:

  1. Save CPU registers, including the CPU instruction pointer and any processor flags, such as carry and parity.
  2. Clear the terminal flag.
  3. Execute the appropriate handling.
  4. Restore the CPU registers.
  5. Return.

1.7 Polling Pattern

Another commonly used pattern for obtaining data from hardware is periodic checking, known as the polling process. When data or signals are not urgent, or when the hardware lacks the ability to generate interrupts, or when the hardware can retain data until the next read, polling is very useful.

1.7.1 Pattern Structure

Exploring Embedded System Architecture: Programming Design Patterns (Part 1) - Design Patterns for Accessing Hardware

The polling pattern is the simplest way to read data from hardware. Polling can be performed periodically or irregularly; it can be timer-based or read when the system requires it.

1.7.2 Roles

1.7.2.1 Application Process (ApplicationProcessingElement)

This element is used to loop call the poll() operation. This can also occur within timer interrupts.

1.7.2.2 Hardware (Device)

The Device provides data or device status information through accessible functions. This class implements two methods, getData() for data retrieval and getState() for obtaining data status. MAX_POLL_DEVICE connects to all hardware devices so that the poll() function can scan and notify clients.

1.7.2.3 Poller (OpportunisticPoller)

This has a poll() function used to scan connected devices to read data and status and relay this data to clients. This element can also add timer operations to implement periodic data reading.

1.7.2.4 Client (PollDataClient)

This element is a client that receives data and status information from one or more devices.

1.7.3 Effects

Polling is much simpler than using interrupt services and can detect multiple different devices simultaneously, but it is generally not as timely as interrupt responses. Therefore, when using polling, it is best to ensure the longest reading interval to guarantee that data is read at least once within a given time; otherwise, data may be lost, although this is not always a problem, so each situation should be analyzed individually.

1.7.4 Implementation

The simplest display method is to insert hardware checks into the main process loop of the system, known as “symmetric opportunistic polling”, as it always operates in the same manner, even if the length of the processing loop may vary. Asymmetric opportunistic polling reads new data at convenient times during the process (symmetric reads data at fixed locations, while asymmetric reads only when needed). This method offers better responsiveness but has a greater impact on the main process and is harder to maintain.

Leave a Comment