🧩 Why Use Struct-Based Function Encapsulation? #
In embedded systems, struct-based function encapsulation uses function pointers inside structures to bind behavior with data.
This pattern emulates Object-Oriented Programming (OOP) concepts in plain C while remaining lightweight, deterministic, and portable.
Instead of scattering related functions across files, a struct becomes a self-contained driver object that exposes a clean and consistent interface to the application layer.
🧠 Core Concept: Behavior + State #
Traditional C structs package only data. Embedded C extends this idea by embedding function pointers alongside configuration or runtime state.
This creates a logical unit that:
- Owns its internal state
- Exposes a defined API
- Hides hardware-specific implementation details
The caller interacts with what the object does, not how it does it.
🎯 Key Design Advantages #
-
Modularization
Related data and functions live together, improving reuse and readability. -
Abstraction
Hardware details are hidden behind function pointers, enabling clean APIs. -
Polymorphism
Function pointers can be swapped at runtime, allowing different implementations under the same interface. -
Scalability
New features can be added without breaking existing code paths.
🏗️ Common Embedded Use Cases #
Struct-based function encapsulation is widely used in:
-
Hardware Abstraction Layers (HAL)
Unified interfaces for SPI, I2C, UART, GPIO, and timers. -
Callback Systems
Passing behavior into drivers, ISRs, or middleware. -
State Machines
Each state contains a pointer to its handler function. -
Task Management
Lightweight task objects with their own execution logic.
🔌 Example: SPI Hardware Interface #
Encapsulating SPI operations allows the application to remain completely unaware of register-level details.
typedef struct {
void (*init)(void);
void (*write)(uint8_t data);
uint8_t (*read)(void);
} spi_t;
/* Implementation functions */
void spi_init(void) { /* Register initialization */ }
void spi_write(uint8_t data) { /* TX logic */ }
uint8_t spi_read(void) { return 0; }
int main(void) {
spi_t my_spi = {
.init = spi_init,
.write = spi_write,
.read = spi_read
};
my_spi.init();
my_spi.write(0xAA);
uint8_t data = my_spi.read();
return 0;
}
This pattern enables easy replacement of the SPI backend without touching application code.
💡 Example: PWM Control for LEDs #
Using structs allows multiple PWM-controlled devices to share the same logic with different configurations.
typedef struct {
uint8_t duty_cycle;
void (*set_duty)(uint8_t duty);
void (*start)(void);
void (*stop)(void);
} pwm_control_t;
void set_led_duty(uint8_t duty) { /* Timer compare update */ }
void start_led_pwm(void) { /* Enable PWM timer */ }
int main(void) {
pwm_control_t led_red = {
.duty_cycle = 50,
.set_duty = set_led_duty,
.start = start_led_pwm
};
led_red.set_duty(led_red.duty_cycle);
led_red.start();
}
Each instance acts as an independent “object” with shared behavior.
🧰 Maintenance and Long-Term Benefits #
-
Information Hiding The application depends only on the interface, not the implementation.
-
Safe Extension New functionality can be added by extending the struct definition.
-
Reduced Global State State is localized, minimizing unintended side effects.
-
Improved Testability Mock implementations can replace real hardware functions during testing.
🧪 When This Pattern Shines #
Struct-based function encapsulation is ideal when:
- Hardware varies across platforms
- Drivers must be reused across projects
- Runtime flexibility is required
- Full C++ is unavailable or undesirable
It strikes a practical balance between bare-metal efficiency and architectural clarity.
Bottom line: This pattern gives you OOP-style structure, polymorphism, and abstraction—without sacrificing the predictability and control that embedded C demands.