- Published on
OOP for Embedded Firmware on ESP32
- Authors
📘 Foundation Article 5: OOP for Embedded Firmware on ESP32
Track: C++ Firmware Engineering Foundations for ESP32 Environment: ESP32 + VSCode + Arduino Community Edition Project Base: IndustrialNode/IndustrialNode.ino Use Case: Generic Sensor + Relay Node
- 📘 Foundation Article 5: OOOP for Embedded Firmware on ESP32
- 1️⃣ OOP di Embedded Bukan Soal Gaya
- 2️⃣ Encapsulation sebagai Boundary Hardware
- 3️⃣ Visual: Dari Global Chaos ke Ownership Model
- 4️⃣ Constructor & Lifecycle
- 5️⃣ Composition Lebih Aman daripada Inheritance
- 6️⃣ Object Size, Memory Footprint & Stack Impact
- 7️⃣ OOP vs Stack Usage
- 8️⃣ Hidden Allocation & OOP
- 8️⃣ Global Variable vs Object State
- 9️⃣ OOP dalam Konteks ISR & RTOS
- 🔬 IndustrialNode Pattern (Stabil)
- OOP Bisa Menghancurkan Determinism
- 🔟 Mental Model yang Harus Dibentuk
1️⃣ OOP di Embedded Bukan Soal Gaya
Masalah firmware Arduino klasik bukan karena C.
Masalahnya adalah ownership chaos.
Contoh klasik:
int relayPin = 5;
bool relayState = false;
void setRelay(bool on) {
relayState = on;
digitalWrite(relayPin, on);
}
Masalah fundamental:
relayStateglobal mutable.- Tidak ada owner.
- ISR bisa ubah.
- MQTT callback bisa ubah.
- loop() bisa ubah.
- Tidak ada boundary.
Ini bukan masalah bahasa. Ini masalah architecture discipline.
Failure Model — Global Mutable + Concurrency
ISR → relayState
MQTT → relayState
loop → relayState
Timer → relayState
Race condition bukan kemungkinan.
Ia hanya soal waktu.
OOP sebagai Solusi Ownership
Ubah menjadi:
class RelayDriver {
public:
explicit RelayDriver(int pin)
: pin_(pin), state_(false) {}
void set(bool on) {
state_ = on;
digitalWrite(pin_, on);
}
bool state() const {
return state_;
}
private:
int pin_;
bool state_;
};
Sekarang:
- State private.
- Hanya bisa dimodifikasi lewat method.
- Ownership jelas.
- Boundary terbentuk.
Encapsulation di embedded = boundary control.
2️⃣ Encapsulation sebagai Boundary Hardware
Hardware adalah resource terbatas.
Jika Anda expose pin langsung:
digitalWrite(5, HIGH);
Siapa pun bisa mengakses.
Tidak ada policy.
Pattern Tanpa Boundary
loop()
ISR()
MQTT callback()
Timer callback()
Semua langsung ke digitalWrite().
Tidak ada arbitration.
Pattern Dengan Boundary
ISR → Event
MQTT → Event
loopTask → RelayDriver::set()
RelayDriver menjadi satu-satunya pemilik pin.
IndustrialNode Context
Struktur kita:
IndustrialNode/
├── drv_RelayDriver.cpp
├── svc_SensorService.cpp
├── app_ControlApp.cpp
Boundary:
- Driver hanya tahu hardware.
- Service tahu logic sensor.
- App tahu orchestration.
Ini bukan style. Ini dependency control.
3️⃣ Visual: Dari Global Chaos ke Ownership Model




Model Lama (Chaos)
+----------------+
| ISR |
+----------------+
↓
+----------------+
| relayPin (global)
+----------------+
↑
+----------------+
| MQTT callback |
+----------------+
↑
+----------------+
| loop() |
+----------------+
Tidak ada ownership. Tidak ada boundary. Tidak ada arbitration.
Model OOP (Ownership)
ISR → Queue
MQTT → Queue
↓
ControlApp
↓
RelayDriver
↓
Hardware Pin
Sekarang:
- Satu owner hardware.
- Event masuk melalui channel.
- State dikelola dalam domain.
4️⃣ Constructor & Lifecycle
Constructor di embedded bukan sekadar inisialisasi variable.
Ia adalah:
Resource binding moment.
Contoh:
RelayDriver relay(5);
Artinya:
- Pin 5 milik relay.
- State awal terdefinisi.
- Resource terikat sejak startup.
Failure Model — Constructor di Loop
void loop() {
RelayDriver relay(5);
relay.set(true);
}
Setiap loop:
- Constructor dipanggil.
- State reset.
- Tidak align dengan system lifetime.
- Stack churn.
- Potential hidden cost.
Correct Pattern — System Lifetime Object
RelayDriver relay(5);
void setup() {
relay.set(false);
}
void loop() {
relay.set(true);
}
Object hidup sepanjang sistem.
Lifecycle Rule Foundation
Object domain utama:
- Harus hidup sepanjang sistem.
- Tidak dibuat/dihancurkan berulang.
- Tidak bergantung pada heap runtime.
Jika lifetime tidak align, memory discipline (Article 4) hancur.
5️⃣ Composition Lebih Aman daripada Inheritance
Inheritance terlihat profesional.
Contoh:
class Device {
public:
virtual void update() = 0;
};
class RelayDriver : public Device {
public:
void update() override {
// ...
}
};
Secara akademik benar.
Tetapi dalam embedded, kita harus bertanya:
- Apakah polymorphism benar-benar diperlukan?
- Apakah ada lebih dari satu implementasi?
- Apakah kita butuh runtime dispatch?
- Apakah vtable cost bisa diterima?
Apa yang Terjadi Saat Anda Gunakan virtual?
Compiler membuat:
- vtable
- pointer ke vtable
- dynamic dispatch
- tambahan indirection
Ilustrasi sederhana:
Object:
+------------------+
| vptr ----------+ |
| member1 | |
| member2 | |
+------------------+ |
↓
vtable
+----------------+
| &Relay::update |
+----------------+
Setiap object dengan virtual function:
- Memiliki pointer tambahan.
- Tidak bisa dioptimalkan secara statik sepenuhnya.
- Lebih sulit dianalisis ukuran finalnya.
Embedded Reality Check
Jika hanya ada satu implementasi:
- Inheritance tidak memberikan value.
- Tetapi tetap menambah complexity.
Composition — Lebih Eksplisit & Deterministik
class ActuatorService {
public:
explicit ActuatorService(RelayDriver& relay)
: relay_(relay) {}
void update(bool on) {
relay_.set(on);
}
private:
RelayDriver& relay_;
};
Keuntungan:
- Tidak ada vtable.
- Tidak ada dynamic dispatch.
- Tidak ada runtime polymorphism.
- Dependency eksplisit.
- Ownership jelas.
Foundation Rule
Gunakan inheritance hanya jika:
- Benar-benar butuh polymorphism.
- Ada lebih dari satu implementasi.
- Lifetime dan ownership jelas.
Default di embedded:
Composition first. Polymorphism last.
6️⃣ Object Size, Memory Footprint & Stack Impact
Engineer sering lupa:
Class mempengaruhi memory layout.
Contoh sederhana:
class A {
int x;
bool flag;
};
Ukuran bukan 5 byte. Alignment bisa membuatnya 8 byte.
Sekarang tambahkan:
virtual void foo();
Ukuran bertambah lagi (vptr).
Dalam sistem kecil:
- Banyak object kecil.
- Stack per task terbatas.
- Alignment cost signifikan.
Audit Object Size
Tambahkan saat development:
Serial.print("Size of RelayDriver: ");
Serial.println(sizeof(RelayDriver));
Lakukan untuk semua class utama.
Jika object besar dan diletakkan di stack:
Stack pressure meningkat.
Ini menghubungkan langsung ke Article 4.
7️⃣ OOP vs Stack Usage
Failure model klasik:
void loop() {
ControlApp app(sensor, relay);
app.update();
}
Object dibuat di stack tiap loop.
Jika ControlApp memiliki:
- Array internal.
- Buffer.
- Vector.
- State besar.
Stack consumption melonjak.
Pattern Stabil
ControlApp app(sensor, relay);
void loop() {
app.update();
}
Object global static.
Stack loopTask hanya menyimpan:
- Return address
- Frame kecil
Deterministik.
8️⃣ Hidden Allocation & OOP
Beberapa pola OOP modern yang berisiko:
- std::function
- std::bind
- std::vector
- std::map
- std::string
- Polymorphic container
Contoh yang tampak sederhana:
std::function<void()> callback;
Bisa:
- Allocate heap.
- Copy lambda capture.
- Reallocate.
Anda tidak melihatnya. Tetapi heap melihatnya.
OOP Trap dalam Embedded
Desain seperti ini terlihat elegan:
class EventBus {
public:
void subscribe(std::function<void()> handler);
};
Tetapi:
- std::function → allocation.
- Handler capture → allocation.
- Subscriber list → vector → reallocation.
Dalam sistem uptime panjang:
Fragmentation generator.
Foundation Discipline
Jika butuh callback:
Gunakan pointer function sederhana:
typedef void (*Handler)();
Atau referensi statik.
Minimalkan abstraction cost.
8️⃣ Global Variable vs Object State
Masalah global mutable bukan hanya style.
Masalahnya adalah:
Tidak ada owner.
Contoh klasik:
bool relayState = false;
Siapa boleh ubah?
- loop()
- ISR
- MQTT callback
- Timer callback
- Future feature
Semua bisa.
Dan saat concurrency masuk, global mutable menjadi race magnet.
Failure Model — Global + RTOS
loopTask → relayState = true
MQTT Task → relayState = false
ISR → relayState = toggle
Timer Task → relayState = true
Tidak ada arbitration. Tidak ada ordering guarantee. Tidak ada ownership.
Object State = Ownership Boundary
class RelayDriver {
public:
void set(bool on) {
state_ = on;
digitalWrite(pin_, on);
}
private:
bool state_;
int pin_;
};
Sekarang:
- Hanya RelayDriver yang punya state.
- Tidak ada akses langsung ke state_.
- Tidak ada shared mutable global.
Ini bukan “OOP cantik”.
Ini concurrency discipline.
9️⃣ OOP dalam Konteks ISR & RTOS
Sekarang kita gabungkan Article 2 + 3 + 4 + 5.
ISR tidak boleh:
- Blocking
- Allocation
- Heavy logic
- Menyentuh shared object sembarangan
Pattern Salah — ISR Memanggil Object Langsung
void IRAM_ATTR buttonISR() {
relay.set(true);
}
Masalah:
- relay mungkin bukan thread-safe.
- relay mungkin dipakai task lain.
- Tidak ada arbitration.
- Potensi race.
- Potensi deadlock jika internal pakai mutex.
Pattern Benar — ISR Kirim Event
volatile bool buttonPressed = false;
void IRAM_ATTR buttonISR() {
buttonPressed = true;
}
Atau lebih baik:
ISR → Queue → ControlTask → RelayDriver
OOP + Queue Model
ISR
↓
Queue<Event>
↓
ControlApp::process()
↓
RelayDriver::set()
Sekarang:
- ISR tidak menyentuh hardware driver langsung.
- ControlApp memiliki ownership logic.
- RelayDriver hanya menerima command.
OOP membantu enforce boundary ini.
🔬 IndustrialNode Pattern (Stabil)
RelayDriver relay(5);
SensorService sensor;
ControlApp app(sensor, relay);
void setup() {
app.init();
}
void loop() {
app.update();
}
ControlApp adalah:
- Orchestrator
- Single owner of system state
- Single place for decision logic
RelayDriver tidak tahu tentang MQTT. SensorService tidak tahu tentang ISR. Layer separation jelas.
OOP Bisa Menghancurkan Determinism
Sekarang sisi gelapnya.
Jika OOP digunakan tanpa discipline:
- Dynamic allocation dalam constructor.
- std::function callback.
- vector dynamic.
- new/delete di hot path.
- Polymorphic container.
Maka:
- Fragmentation meningkat.
- Heap spike sulit diprediksi.
- Stack usage tidak terkontrol.
- Debugging menjadi kompleks.
OOP tidak otomatis aman.
Ia aman hanya jika:
Selaras dengan memory & concurrency discipline.
🔟 Mental Model yang Harus Dibentuk
Setelah Article 5, engineer harus memahami:
1️⃣ OOP bukan gaya
Ia adalah tool untuk:
- Ownership clarity
- Boundary enforcement
- State isolation
2️⃣ Encapsulation = concurrency shield
Private state mencegah chaos global.
3️⃣ Constructor = resource binding
Object harus align dengan system lifetime.
4️⃣ Composition lebih aman daripada inheritance
Default embedded = explicit dependency.
5️⃣ Object size memengaruhi stack
Audit sizeof().
6️⃣ Hidden allocation harus dicurigai
std::function, vector, string bukan gratis.
7️⃣ ISR tidak boleh melanggar boundary object
ISR hanya signal. Task yang memiliki object yang memproses.
8️⃣ OOP harus selaras dengan memory discipline
Jika tidak:
- Fragmentation
- Heap exhaustion
- Stack overflow
- Watchdog reset
Penutup — OOP sebagai Structural Control
Dalam firmware ESP32:
OOP bukan tentang:
Membuat sistem terlihat modern.
OOP adalah tentang:
- Mengontrol siapa memiliki state.
- Mengontrol siapa boleh mengubah.
- Mengontrol lifetime.
- Mengontrol dependency.
- Mengurangi blast radius concurrency.
- Menjaga determinism.
Tanpa OOP discipline:
- Layering di Article 6 akan runtuh.
- Communication architecture akan chaos.
- Reliability discipline akan terasa “terlalu keras”.
Dengan OOP discipline:
Foundation → Production menjadi natural evolution.
Catatan Penyusunan Artikel ini disusun sebagai materi edukasi dan referensi umum berdasarkan berbagai sumber pustaka, praktik lapangan, serta bantuan alat penulisan. Pembaca disarankan untuk melakukan verifikasi lanjutan dan penyesuaian sesuai dengan kondisi serta kebutuhan masing-masing sistem.