Introduction
In many embedded systems, one hardware event or sensor reading must be used by multiple parts of the system. For example:
- An accelerometer generates motion data.
- A logging module stores it in flash.
- A wireless module transmits it.
- A gesture-recognition task processes it.
If each module polls the sensor individually, the system wastes energy and code becomes tangled. If they’re all directly tied together, the system loses flexibility.
The Observer Pattern solves this by allowing multiple subscribers to register interest in a data source. Whenever new data arrives, the source notifies all observers — efficiently, asynchronously, and without tight coupling.
In sustainable, low-power embedded systems, this is critical: it prevents duplicate sensor reads, reduces CPU wakeups, and keeps modules modular. It’s particularly valuable in IoT devices and automotive systems where sensors like accelerometers or collision detectors trigger multiple responses, such as logging, alerting, or actuation.
What is the Observer Pattern?
The Observer Pattern establishes a publish/subscribe relationship between a subject (data producer) and multiple observers (data consumers).
- The subject produces events or sensor data.
- Observers subscribe to the subject and receive notifications whenever data updates.
- The subject does not need to know details about the observers — just that they’re registered.
This decouples data generation from data consumption. Also known as Publish-Subscribe, it divides modules into Subjects (Publishers) that send notifications and Observers (Subscribers) that receive them, often through direct interfaces or centralized brokers.
Responsibilities
- Subject – maintains a list of observers, provides subscribe/unsubscribe APIs, and notifies observers on new data.
- Observer – registers with subject, implements a callback interface to receive data.
- Notification mechanism – ensures efficient event distribution, ISR → task safe. Notifications can follow a push model (detailed data sent with notification) or pull model (minimal notification, observers query for details), balancing memory use and data freshness in constrained environments.
When to Use It
The Observer Pattern is especially useful when:
- One sensor, many consumers (e.g., accelerometer data → logger + wireless + fusion).
- Asynchronous data events need broadcasting (e.g., UART RX, ADC samples).
- Loose coupling is desired — subjects shouldn’t depend on specific consumers.
- Energy efficiency matters — avoid duplicate reads by sharing one data source.
It is less needed when only one consumer exists or when polling is simpler. In RTOS-based systems, it’s ideal for inter-task communication, such as updating multiple tasks from sensor data without busy-waiting.
Benefits
- Loose coupling – subject doesn’t need to know consumers.
- Efficient distribution – one read, many consumers.
- Scalability – easily add/remove subscribers.
- Energy savings – avoids redundant sensor access.
- Improved testability – observers can be mocked or simulated.
- Promotes modularity – supports dynamic behavior in event-driven systems.
- Reduces polling overhead – conserves CPU and power in real-time applications.
Drawbacks
- Requires careful memory and subscription management.
- Ordering/timing may matter — not all observers process at the same speed.
- Risk of notification storms if too many observers or too frequent updates.
- Increases code complexity; potential memory leaks if observers aren’t unregistered.
- Changes in subject interfaces can cascade to observers.
- Must avoid dangling references from deleted observers.
Design Variants
Direct Callback List
Each observer provides a callback function pointer. The subject iterates and calls them.
- Use Case: Small, simple bare-metal systems.
- Pros: Minimal overhead.
- Cons: All observers called in subject context — risky if slow.
Event Queue Distribution
Instead of calling observers directly, the subject enqueues events. Observers dequeue asynchronously.
- Use Case: RTOS systems with multiple tasks consuming the same sensor.
- Pros: Non-blocking subject; observers run independently.
- Cons: Requires queues and more memory. In RTOS like FreeRTOS, use message queues for asynchronous notifications across tasks.
Filtered Observers
Observers register with filters (e.g., “only notify if value > threshold”).
- Use Case: Power-sensitive nodes where not all consumers need every update.
- Pros: Reduces unnecessary notifications.
- Cons: More complex logic in subject.
Observer Chains
Observers can themselves act as subjects, creating chains.
- Use Case: Sensor → Filter → Fusion → Logger + Radio.
- Pros: Modular data pipeline.
- Cons: Can be tricky to debug event flows.
Centralized Broker
A mediator manages subscriptions, mapping subjects to observers and handling updates.
- Use Case: Complex systems with multiple subjects.
- Pros: Centralized control, avoids dependency graphs.
- Cons: Potential single point of failure.
Concurrency & ISR Rules
- ISRs should enqueue, not call observers directly.
- Subjects must document if
notify()
is ISR-safe or task-only. - Use bounded queues to prevent overflow.
- Consider priorities if some observers are more critical.
- For RTOS: differentiate local (same task) and remote (different tasks/processors) publish-subscribe, using message-based interfaces.
Example — Accelerometer with Multiple Subscribers
Bare-Metal Example
// observer_pattern.c #include <stdint.h> #include <stdbool.h> #include <string.h> #define MAX_OBSERVERS 4 typedef void (*observer_cb_t)(const int16_t *sample); static observer_cb_t observers[MAX_OBSERVERS]; static uint8_t num_observers = 0; void accel_subscribe(observer_cb_t cb) { if (num_observers < MAX_OBSERVERS) { observers[num_observers++] = cb; } } void accel_unsubscribe(observer_cb_t cb) { for (uint8_t i=0; i<num_observers; i++) { if (observers[i] == cb) { observers[i] = observers[--num_observers]; // compact list break; } } } // Subject: called from ISR or polling loop when new data ready void accel_new_sample(const int16_t sample[3]) { for (uint8_t i=0; i<num_observers; i++) { observers[i](sample); // notify each observer } } // Example observers void logger_callback(const int16_t *sample) { // Write sample to flash } void radio_callback(const int16_t *sample) { // Send over wireless } int main(void) { // Subscribe two consumers accel_subscribe(logger_callback); accel_subscribe(radio_callback); // Fake sample event int16_t sample[3] = {100, -50, 20}; accel_new_sample(sample); while (1) { // main loop, maybe sleep } }
C++ Object-Oriented Example (Weather Station)
// Observer.h class Observer { public: virtual void update(float temperature, float humidity, float pressure) = 0; virtual ~Observer() {} }; // Subject.h #include <vector> class Subject { public: virtual void registerObserver(Observer* observer) = 0; virtual void removeObserver(Observer* observer) = 0; virtual void notifyObservers() = 0; virtual ~Subject() {} }; // WeatherStation.cpp #include <iostream> #include <algorithm> class WeatherStation : public Subject { private: std::vector<Observer*> observers; float temperature, humidity, pressure; public: void registerObserver(Observer* observer) override { observers.push_back(observer); } void removeObserver(Observer* observer) override { observers.erase(std::remove(observers.begin(), observers.end(), observer), observers.end()); } void notifyObservers() override { for (Observer* observer : observers) { observer->update(temperature, humidity, pressure); } } void setMeasurements(float temp, float hum, float pres) { temperature = temp; humidity = hum; pressure = pres; notifyObservers(); } }; // Display.cpp class Display : public Observer { public: void update(float temperature, float humidity, float pressure) override { std::cout << "Display: Temperature = " << temperature << "°C, Humidity = " << humidity << "%, Pressure = " << pressure << " hPa\n"; } }; // main.cpp int main() { WeatherStation station; Display display1; Display display2; station.registerObserver(&display1); station.registerObserver(&display2); station.setMeasurements(25.5, 60.0, 1013.2); station.setMeasurements(24.8, 58.0, 1014.5); return 0; }
Vehicle Safety System Example
In automotive embedded systems, a Vehicle subject notifies observers like Engine, Airbags, and CollisionSensor of state changes (e.g., start=1, stop=0, crash=2, collision=3). The CollisionSensor detects a collision and updates the vehicle state to trigger airbags. This demonstrates the Observer Pattern in safety-critical systems.
Best Practices & Checklist
- Use abstract observer interfaces (function pointers, vtables, or C++ base classes).
- Keep subject ISR logic minimal — defer notifications to task context if needed.
- Support unsubscribe to prevent dangling pointers.
- Provide bounded subscription capacity.
- Consider observer filters to reduce overhead.
- Test with multiple observers, including fault injection.
- Choose push/pull based on data size; use topics or brokers for complex systems.
Anti-Patterns
Subject hard-codes knowledge of specific observers.
Observers block for long periods inside callbacks.
No unsubscribe support — leads to dangling callbacks.
Unbounded observer list growth.
Observers modifying the subject during notifications, causing cycles.
Comparison: Observer vs Mediator vs Proxy
Pattern | Purpose | Best For | Pros | Cons |
---|---|---|---|---|
Observer | Distribute one event/data to many subscribers | Multiple consumers of same sensor data | Efficient fan-out, decoupled | Needs careful timing mgmt |
Mediator | Coordinate interactions between modules | System-wide policies, orchestration, sharing | Centralized coordination, power savings | Risk of “god object” |
Proxy | Encapsulate hardware access details | Single complex peripheral (codec, radio) | Clean API, testable, portable | Adds indirection, possible lag |
Conclusion
The Observer Pattern is ideal for distributing sensor data efficiently to multiple consumers in embedded systems. It ensures loose coupling, prevents redundant reads, and improves modularity.
By combining observers with other patterns like the Mediator (for orchestration) and Proxy (for device abstraction), you can build embedded systems that are efficient, maintainable, and power-conscious.
Libraries like the Embedded Template Library (ETL) provide ready-to-use Observer implementations for C++ embedded projects.