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



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:

  • relayState global 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

Image

Image

Image

Image

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.