The Challenge of Multi-Generation Hardware Support
In embedded systems, we know that part obsolescence is inevitable. Witekio was recently tasked with modifying an existing device driver to support a replacement component. The project came with firm requirements that the driver had to:
- support the new device;
- maintain full backward compatibility with the original device; and
- preserve the existing interface.
Analysis: Evaluating the Legacy Architecture
First, we needed to understand the nature and extent of the changes required. This analysis was broken down into two parts:
- Hardware Comparison: Comparing data sheets revealed that while both were I2C slave devices, they had fundamentally different methods of operation.
- Software Audit: The existing driver was implemented in a single file of approximately five hundred lines:

Structural Refactoring
From both parts of the analysis, it became clear that the driver would have to be substantially modified to deal with the new device.
Adding more code to the driver in its current state was undesirable. Introducing conditional device-specific code would clutter the main execution flow, increasing complexity making the driver more difficult to maintain.
Therefore, the decision was made to re-architect the driver, breaking it down into components, each with a specific role:

A key feature of the new modular architecture was the Separation of Concerns. Each module has a dedicated role:
- I2C Layer: Providing a simpler I2C interface to the device driver;
- Device Driver: Essentially an abstraction of the two devices (old and new); and
- Specific Device Code: Implementations of the abstract device for the two actual devices.
The existing code (for device A) was refactored to conform with the above architecture, minus the device B specific code. The refactored code was tested to ensure nothing had broken during this stage. At this stage the device driver module made direct calls into functions provided in the device A specific code module.
Solving Hardware Divergences
The next stage was to introduce support for the new device. This was done by temporarily isolating the Device A specific code and then developing specific code for Device B. This phase revealed two issues that would need to be addressed:
- Different I2C Addresses: Boards with differing devices had been designed such that the old and new devices had different I2C addresses. This was a problem because the operating system expected a single common address to be configured;
- Interrupt Mechanism Disparity: The interrupt mechanism for the two devices behaved very differently.
As a temporary measure, new device’s I2C address was hardwired into the software, allowing development to proceed. At the same time, it was decided to introduce an Interrupt Layer to the architecture in order to abstract away the differences in interrupt handling:

This was implemented and the driver was soon working correctly with Device B. Throughout this phase, the primary engineering aim was to evolve an identical API between the driver and the specific code layers for both devices. This was achieved fairly easily.
Bringing it All Together: Dynamic Device Detection
The final stage was to re-introduce the device A specific code and add runtime “intelligence” to the device driver to identify which device it was dealing with. This enabled it to call the appropriate device specific software routines.
In theory, this was fairly straightforward: the driver would probe for the devices using their two different I2C addresses. Whichever address gave a response would allow the driver to select the correct specific code to use. Having done that, the driver could set up a set of function pointers allowing the correct specific code to be called.
However, the operating system’s API did not allow the I2C address of a device to be changed dynamically and so a workaround had to be introduced in the I2C layer that provided the required functionality. This was not easy and required detailed knowledge of the operating system’s handling of I2C addresses.
Once the I2C address could be switched, a probe routine was developed and the driver able to detect which of the two devices it was dealing with and select the correct set of functions to use:

Conclusion: Beyond the Initial Requirements
The completed driver now supports two different devices seamlessly. While the primary goal was hardware compatibility, the refactoring process achieved much more:
- Code Sanitization: During the process, several issues were resolved and unnecessary software was discovered and removed;
- Operational Efficiency: The new architecture is more robust, efficient, and easier to understand; and
- Scalability: Adding further new devices will now be a much easier.



