Hello! Welcome to your next lesson in the "System Design for Embedded Applications" module.
In our last session, we developed a high-level framework for tackling system design interview questions. We focused on clarifying requirements, sketching architectures, and justifying trade-offs for entire systems. A recurring theme was the importance of a layered software architecture.
Today, we're going to zoom in on that concept and apply it to a fundamental building block of any embedded system: the device driver. Your ability to design drivers that are portable, testable, and maintainable is a critical skill evaluated in senior engineering interviews.
Your learning outcome for this lesson is to: Design a layered device driver architecture that separates protocol logic from physical bus access.
We will explore this principle through three lenses: a practical from-scratch implementation, a formal C design pattern for portability, and its application in large-scale industry standards.
1. The Layered Driver in Practice: An I2C Case Study
The core idea of a layered driver architecture is to separate the what from the how.
- Protocol Logic (The "What"): This layer understands the specific device you're talking to. It knows the device's register map, the sequence of commands needed to initialize it, and how to interpret the data it returns. It speaks the language of the device.
- Physical Bus Access (The "How"): This layer understands the communication bus itself (e.g., I2C, SPI). It knows how to manipulate the microcontroller's peripheral registers to generate start/stop conditions, send bytes, and handle acknowledgements. It speaks the language of the bus protocol and the MCU hardware.
Let's see this in action. The following video provides an excellent, detailed walkthrough of building an I2C driver and then layering a sensor-specific driver on top of it.
I2C driver in C (with VL53L0X sensor) | Embedded System Project Series #23
The video 'I2C driver in C' from the Artful Bytes channel is a complete case study of a layered driver design. The author first builds a generic I2C driver for an MSP430 MCU and then uses that driver to communicate with a VL53L0X time-of-flight sensor.
Please watch the following two segments: Defining the Interface (00:21 - 05:13): Pay close attention to the i2c.h header file being created. The functions i2c_write() and i2c_read() form a generic interface for byte-level bus communication. This is our Physical Bus Access layer. It makes no assumptions about the sensor it will talk to. Layering the Sensor Driver (40:10 - 43:20): Now, observe how the higher-level vl53l0x sensor driver is introduced. The author explicitly states he's adding the 'layer above'. This driver contains nearly a thousand lines of code specific to the sensor's complex protocol. This is our Protocol Logic layer. It uses the simple i2c_write() and i2c_read() functions to execute its commands, completely unaware of the underlying MSP430 I2C peripheral registers.
This practical example clearly demonstrates the separation of concerns.

The benefit is immediate: if you needed to use a different I2C sensor, you would only need to write a new "protocol logic" driver for that sensor; the underlying i2c.c driver would remain unchanged.
2. Formalizing Portability: The HAL Design Pattern
The previous example is a great start, but the i2c.c driver is still tightly coupled to the MSP430 microcontroller. What if you needed to port your application and the vl53l0x driver to a different MCU, say one from STMicroelectronics or NXP? You would have to rewrite i2c.c and potentially change any code that calls it if the function signatures differ.
A more robust and portable solution is to define the physical bus access layer as an interface using a struct of function pointers. This is a powerful technique in C for implementing a Hardware Abstraction Layer (HAL).
Creating a Hardware Abstraction Layer (HAL) in C
The article 'Creating a Hardware Abstraction Layer (HAL) in C' by Jacob Beningo provides a clear, professional pattern for achieving true hardware independence. It moves beyond direct function calls to an interface-based design.
Please read the sections from 'Defining an I2C hardware abstraction layer' through 'Making your driver fit the interface'. Focus on these key points: The Interface Struct: The I2C_t struct, which contains only function pointers, defines the abstract interface for any I2C driver. Mapping the Implementation: See how a concrete driver instance, I2C0, is created by assigning actual function implementations (I2C0_Initialize, I2C0_Write, etc.) to the function pointers in the struct. Adapting Vendor Drivers: The article shows how to create simple wrapper functions (like SERCOM0_I2C_Write) to adapt a vendor's non-matching function signature to the one expected by your HAL interface. This is a crucial technique for working with different SDKs.
Let's summarize the architecture this pattern enables:
-
Application / Protocol Logic (
vl53l0x.c):- This layer no longer includes
"i2c.h"and callsi2c_write()directly. - Instead, it is given a pointer to an
I2C_tobject, let's call itbus_if. - To write to the bus, it calls
bus_if->Write(...). It has no knowledge of the underlying MCU.
- This layer no longer includes
-
The HAL Interface (
i2c_interface.h):- Contains only the definition of the
I2C_tstruct with function pointers.
- Contains only the definition of the
-
Concrete Implementation (
msp430_i2c.corstm32_i2c.c):- This file implements the I2C peripheral control for a specific MCU.
- It exposes an
I2C_tobject, initialized with pointers to its own functions.
This design decouples the protocol logic from the physical access layer completely. To port your application to a new MCU, you simply write a new mcu_xyz_i2c.c implementation and link your application against it. No changes are needed in the protocol driver. This is a hallmark of professional embedded software design and a great point to discuss in an interview.
3. Scaling the Concept: Industry Standards
This layered approach isn't just for simple drivers; it's the foundation of how complex, distributed systems are designed, especially in the automotive field. Standards like AUTOSAR and the Functional Mock-up Interface (FMI) formalize this separation to allow for simulation and integration of components from different suppliers.
Let's look at the FMI Layered Standard for Bus Communication as an example of this pattern at scale.
Layered Standard for Network Communication
The 'Layered Standard for Network Communication' defines a standardized way for virtual ECUs (called FMUs) to communicate. We will not dive into the full standard, but instead, observe how it perfectly mirrors the layered driver pattern we've been discussing.
Please read the section '5.1. Overview' and skim the tables in '5.2. Layered Standard Bus Protocol'. You don't need to understand every detail. Focus on the high-level concepts: Bus Operations: These are standardized binary messages representing protocol-level commands (e.g., CAN Transmit). The FMU (your application) creates these operations. This is the protocol logic. Look at Table 9 for the CAN Transmit operation—it contains the CAN ID, data, etc., but nothing about the physical bus timing. Bus Simulation FMU / Importer: This component receives the Bus Operations and is responsible for simulating the actual bus behavior, including timing, arbitration, and errors. This is the physical bus access layer. Notice the parallel: The Application/ECU creates a high-level command (CAN Transmit operation), which is then handed off to a lower layer (the Bus Simulation) that knows how to actually put it on the wire.
Seeing this pattern appear everywhere—from a simple sensor driver to automotive standards to a full-blown OS—should solidify its importance.
Conclusion
In this lesson, we dissected the concept of a layered device driver architecture, a critical design pattern for creating robust and portable embedded software.
Key Takeaways:
- Separate What from How: A layered driver isolates the device-specific protocol logic (what to say) from the MCU-specific physical bus access (how to say it).
- Interfaces are Key: The boundary between layers is a well-defined interface. For maximum portability in C, a
structof function pointers provides a powerful way to define a Hardware Abstraction Layer (HAL). - The Pattern Scales: This architectural principle applies universally, from a single MCU driver to complex, industry-wide standards for distributed systems like AUTOSAR and FMI, and operating systems like Linux.
- Benefits: This approach leads to code that is more reusable, portable across different hardware platforms, and far easier to test (as you can "mock" the physical layer to test your protocol logic in isolation).
Preview of the Next Module:
We have now concluded the "System Design for Embedded Applications" module. You have architected systems at a high level and now designed a key low-level component. The next logical step is to verify and debug these designs on actual hardware.
Our next module, "Advanced Debugging and System Profiling," will equip you with the hands-on tools to do just that. Our first lesson will be: Use a JTAG/SWD debugger to set hardware/software breakpoints, inspect memory and CPU registers, and step through code on-target.