Áp dụng C++ và OOP cho vi điều khiển: class Led, Button trên ESP32

Áp dụng C++ và OOP cho vi điều khiển: class Led, Button trên ESP32 - Khuê Nguyễn Creator

Trong bài này chúng ta sẽ áp dụng C++ và OOP cho vi điều khiển, với ví dụ chạy thật trên ESP32: viết class Led, Button, Blinker để bật tắt nháy LED bằng nút nhấn, dùng smart pointer đúng cách trên chip ít RAM, và đo xem tính năng nào của C++ tốn bao nhiêu bộ nhớ. Đây là bài cuối của serie, là cầu nối từ C++ trên máy tính sang các serie lập trình vi điều khiển trên blog.

Bài 41 trong Serie Lập trình C++ từ A tới Z

Tại sao dùng C++ và OOP cho vi điều khiển?

Nhiều bạn nghĩ C++ chỉ dành cho máy tính, còn vi điều khiển thì phải viết C. Thực tế các bạn đã dùng C++ từ lâu rồi: mỗi lần gọi Serial.begin(115200) trên Arduino, Serial chính là một đối tượng của class HardwareSerial.

Lợi ích lớn nhất của C++ là gom dữ liệu và hàm xử lý vào một chỗ. Với C, một nút nhấn cần vài biến toàn cục rời rạc: chân GPIO, trạng thái lần trước, thời điểm thay đổi. Có 3 nút là thành 9 biến, đặt tên btn1_last, btn2_last… rất dễ nhầm.

Với C++, các bạn viết class Button một lần, cần bao nhiêu nút thì tạo bấy nhiêu đối tượng. Đây chính là tính đóng gói mà chúng ta đã học.

Giống như ổ cắm điện trong nhà. Các bạn không cần biết dây đi trong tường thế nào, chỉ cần cắm phích vào là có điện. Class Button cũng vậy: bên ngoài chỉ cần gọi pressed(), chuyện chống dội phím bên trong để class tự lo.

Chuẩn bị

  • Board: ESP32 DevKit, dùng luôn LED trên board (GPIO2) và nút BOOT (GPIO0), không cần nối thêm linh kiện
  • Phần mềm: VS Code + PlatformIO, framework Arduino

Nếu các bạn chưa quen ESP32, hãy xem qua Serie Lập trình ESP32 từ A tới Z trước nhé.

Sơ đồ chân ESP32 DevKit V1, khoanh đỏ GPIO2 nối LED, GPIO0 và nút BOOT dùng cho bài OOP cho vi điều khiển

Lưu ý: Trên một số board ESP32 DevKit, LED gắn sẵn không nằm ở GPIO2 hoặc không có LED. Nếu nạp code mà LED không nháy, các bạn kiểm tra sơ đồ board, hoặc nối một LED kèm điện trở 220 ohm từ GPIO2 xuống GND.

Viết class Led

class Led {
public:
    Led(uint8_t pin, bool activeHigh = true) : pin_(pin), activeHigh_(activeHigh) {}

    void begin() {
        pinMode(pin_, OUTPUT);
        off();
    }
    void on() { write(true); }
    void off() { write(false); }
    void toggle() { write(!state_); }
    bool isOn() const { return state_; }

private:
    void write(bool on) {
        state_ = on;
        digitalWrite(pin_, (on == activeHigh_) ? HIGH : LOW);
    }

    uint8_t pin_;
    bool activeHigh_;
    bool state_ = false;
};

Chúng ta cùng phân tích nhé:

  • Hàm khởi tạo nhận chân GPIO và tham số activeHigh có giá trị mặc định là true. Có LED nối kiểu sáng khi xuất mức 0, lúc đó chỉ cần truyền false, bên ngoài vẫn gọi on(), off() như bình thường.
  • Hàm write để ở private vì chỉ dùng nội bộ. Code bên ngoài không thể ghi thẳng ra chân GPIO làm sai lệch biến state_.
  • Tên biến thành viên có dấu gạch dưới ở cuối để phân biệt với biến cục bộ, đây là quy ước rất phổ biến.

Tại sao phải có hàm begin()?

Các bạn để ý hàm khởi tạo không gọi pinMode, việc đó để dành cho begin(). Lý do là các đối tượng toàn cục được khởi tạo trước khi hàm setup() chạy, thậm chí trước khi framework khởi tạo xong phần cứng.

Lưu ý: Không gọi các hàm thao tác phần cứng như pinMode, Serial.begin, Wire.begin trong hàm khởi tạo của đối tượng toàn cục. Tùy framework, lệnh đó có thể chạy trước khi phần cứng sẵn sàng hoặc bị ghi đè sau đó, và lỗi kiểu này rất khó tìm. Đây là lý do gần như mọi thư viện Arduino đều có hàm begin() riêng.

Viết class Button có chống dội phím

Nút nhấn cơ khí khi nhấn sẽ bị rung, tín hiệu nhảy lên xuống vài lần trong vài mili giây. Nếu không xử lý, một lần nhấn có thể bị đếm thành 2, 3 lần.

class Button {
public:
    Button(uint8_t pin, uint32_t debounceMs = 30) : pin_(pin), debounceMs_(debounceMs) {}

    void begin() { pinMode(pin_, INPUT_PULLUP); }

    // Trả về true đúng 1 lần mỗi lần nhấn
    bool pressed() {
        int reading = digitalRead(pin_);
        uint32_t now = millis();
        if (reading != lastRead_) {
            lastRead_ = reading;
            lastChange_ = now;
        }
        if (now - lastChange_ >= debounceMs_ && reading != stable_) {
            stable_ = reading;
            return stable_ == LOW;
        }
        return false;
    }

private:
    uint8_t pin_;
    uint32_t debounceMs_;
    int stable_ = HIGH;
    int lastRead_ = HIGH;
    uint32_t lastChange_ = 0;
};

Ý tưởng chống dội: mỗi khi tín hiệu đọc được thay đổi, ghi lại thời điểm. Chỉ khi tín hiệu giữ nguyên đủ 30ms mới coi là trạng thái ổn định. Nút BOOT nối kéo lên nên khi nhấn sẽ đọc được mức LOW, vì vậy hàm trả về true khi trạng thái ổn định mới là LOW.

Phép trừ now - lastChange_ với kiểu không dấu uint32_t vẫn đúng cả khi millis() tràn về 0 sau khoảng 49 ngày. Đây là cách viết chuẩn khi so sánh thời gian trên vi điều khiển, các bạn nên nhớ.

Viết class Blinker nháy LED không dùng delay

class Blinker {
public:
    Blinker(Led &led, uint32_t periodMs) : led_(led), periodMs_(periodMs) {}

    void setEnabled(bool enabled) {
        enabled_ = enabled;
        if (!enabled_) {
            led_.off();
        }
    }
    bool enabled() const { return enabled_; }

    // Gọi liên tục trong loop(), không dùng delay()
    void update() {
        if (!enabled_) {
            return;
        }
        uint32_t now = millis();
        if (now - lastToggle_ >= periodMs_) {
            lastToggle_ = now;
            led_.toggle();
        }
    }

private:
    Led &led_;
    uint32_t periodMs_;
    bool enabled_ = false;
    uint32_t lastToggle_ = 0;
};

Tại sao không dùng delay(500) cho đơn giản? Vì trong lúc delay, CPU đứng chờ và không đọc được nút nhấn. Các bạn sẽ phải nhấn giữ nút đúng lúc mới ăn. Với millis(), hàm update() chỉ kiểm tra đã tới lúc đảo LED chưa rồi thoát ngay, vòng loop() chạy liên tục nên nút nhấn luôn được đọc kịp.

Blinker không tự tạo LED mà nhận một tham chiếu tới đối tượng Led có sẵn. Cách thiết kế này gọi là composition: Blinker dùng Led chứ không kế thừa Led. Muốn nháy LED khác chỉ cần truyền đối tượng khác vào.

Ghép lại trong setup() và loop()

Led led(2);                 // LED trên board ESP32 DevKit
Button button(0);           // nút BOOT
Blinker blinker(led, 500);

void setup() {
    Serial.begin(115200);
    led.begin();
    button.begin();
    Serial.println("San sang, nhan nut BOOT de bat/tat nhay LED");
}

void loop() {
    if (button.pressed()) {
        blinker.setEnabled(!blinker.enabled());
        Serial.printf("Nhay LED: %s\n", blinker.enabled() ? "BAT" : "TAT");
    }
    blinker.update();
}

Hàm loop() giờ chỉ còn vài dòng và đọc gần như tiếng Anh: nếu nút được nhấn thì đảo trạng thái nháy, sau đó cập nhật LED. Toàn bộ chi tiết đã nằm gọn trong các class.

Cả 3 đối tượng đều là biến toàn cục, bộ nhớ được cấp sẵn lúc biên dịch, không có new nào. Đây là cách làm mình khuyên dùng trên vi điều khiển.

Code đầy đủ

Đây là full code, các bạn copy vào file src/main.cpp trong project PlatformIO là chạy được:

#include <Arduino.h>

class Led {
public:
    Led(uint8_t pin, bool activeHigh = true) : pin_(pin), activeHigh_(activeHigh) {}

    void begin() {
        pinMode(pin_, OUTPUT);
        off();
    }
    void on() { write(true); }
    void off() { write(false); }
    void toggle() { write(!state_); }
    bool isOn() const { return state_; }

private:
    void write(bool on) {
        state_ = on;
        digitalWrite(pin_, (on == activeHigh_) ? HIGH : LOW);
    }

    uint8_t pin_;
    bool activeHigh_;
    bool state_ = false;
};

class Button {
public:
    Button(uint8_t pin, uint32_t debounceMs = 30) : pin_(pin), debounceMs_(debounceMs) {}

    void begin() { pinMode(pin_, INPUT_PULLUP); }

    // Trả về true đúng 1 lần mỗi lần nhấn
    bool pressed() {
        int reading = digitalRead(pin_);
        uint32_t now = millis();
        if (reading != lastRead_) {
            lastRead_ = reading;
            lastChange_ = now;
        }
        if (now - lastChange_ >= debounceMs_ && reading != stable_) {
            stable_ = reading;
            return stable_ == LOW;
        }
        return false;
    }

private:
    uint8_t pin_;
    uint32_t debounceMs_;
    int stable_ = HIGH;
    int lastRead_ = HIGH;
    uint32_t lastChange_ = 0;
};

class Blinker {
public:
    Blinker(Led &led, uint32_t periodMs) : led_(led), periodMs_(periodMs) {}

    void setEnabled(bool enabled) {
        enabled_ = enabled;
        if (!enabled_) {
            led_.off();
        }
    }
    bool enabled() const { return enabled_; }

    // Gọi liên tục trong loop(), không dùng delay()
    void update() {
        if (!enabled_) {
            return;
        }
        uint32_t now = millis();
        if (now - lastToggle_ >= periodMs_) {
            lastToggle_ = now;
            led_.toggle();
        }
    }

private:
    Led &led_;
    uint32_t periodMs_;
    bool enabled_ = false;
    uint32_t lastToggle_ = 0;
};

Led led(2);                 // LED trên board ESP32 DevKit
Button button(0);           // nút BOOT
Blinker blinker(led, 500);

void setup() {
    Serial.begin(115200);
    led.begin();
    button.begin();
    Serial.println("San sang, nhan nut BOOT de bat/tat nhay LED");
}

void loop() {
    if (button.pressed()) {
        blinker.setEnabled(!blinker.enabled());
        Serial.printf("Nhay LED: %s\n", blinker.enabled() ? "BAT" : "TAT");
    }
    blinker.update();
}

Kết quả

Build bằng PlatformIO, chương trình dùng 6.6% RAM và 20.6% Flash của ESP32. Phần lớn là của framework Arduino, 3 class của chúng ta gần như không đáng kể:

PlatformIO build chương trình C++ cho ESP32: RAM 6.6%, Flash 20.6%

Nạp code xong, mở Serial Monitor ở baud 115200 và nhấn nút EN để reset board. Nhấn nút BOOT một lần, LED trên board bắt đầu nháy với chu kỳ 0.5 giây sáng, 0.5 giây tắt, Serial Monitor in “Nhay LED: BAT”. Nhấn thêm lần nữa, LED tắt hẳn và in “Nhay LED: TAT”.

Serial Monitor ESP32 khi nhấn nút BOOT 2 lần: Nhay LED BAT rồi TAT

Đây là log thật khi nhấn BOOT 2 lần, cách nhau khoảng 3 giây. Mỗi lần nhấn chỉ in đúng 1 dòng, chứng tỏ phần chống dội phím đã hoạt động.

Smart pointer trên vi điều khiển

Ở trên cả 3 đối tượng đều là biến toàn cục, không có cấp phát động. Nhưng có lúc loại đối tượng chỉ biết khi chương trình chạy, ví dụ cùng một firmware cho 2 phiên bản board: bản có LED thì báo bằng LED, bản không có LED thì báo qua Serial. Đây là lúc dùng đa hình kết hợp Factory pattern ở chương design pattern và std::unique_ptr:

#include <Arduino.h>
#include <memory>

class DenBao {
public:
    virtual ~DenBao() {}
    virtual void begin() = 0;
    virtual void bao(bool bat) = 0;
};

class DenLed : public DenBao {
public:
    explicit DenLed(uint8_t pin) : pin_(pin) {}
    void begin() override { pinMode(pin_, OUTPUT); }
    void bao(bool bat) override { digitalWrite(pin_, bat ? HIGH : LOW); }

private:
    uint8_t pin_;
};

class DenSerial : public DenBao {          // board khong co LED: bao qua Serial
public:
    void begin() override {}
    void bao(bool bat) override { Serial.println(bat ? "[DEN] BAT" : "[DEN] TAT"); }
};

// Factory: chon loai den bao luc khoi dong, chi cap phat 1 lan
std::unique_ptr<DenBao> taoDenBao(bool coLed) {
    if (coLed)
        return std::unique_ptr<DenBao>(new DenLed(2));
    return std::unique_ptr<DenBao>(new DenSerial());
}

std::unique_ptr<DenBao> den;

void setup() {
    Serial.begin(115200);
    Serial.printf("Heap truoc khi tao: %u byte\n", ESP.getFreeHeap());
    den = taoDenBao(false);
    den->begin();
    Serial.printf("Heap sau khi tao:   %u byte\n", ESP.getFreeHeap());
    Serial.printf("sizeof(unique_ptr) = %u, sizeof(con tro thuong) = %u\n",
                  (unsigned)sizeof(den), (unsigned)sizeof(DenBao*));
}

void loop() {
    static bool bat = false;
    bat = !bat;
    den->bao(bat);
    delay(1000);
}

Chạy unique_ptr trên ESP32: heap giảm 20 byte, unique_ptr có kích thước 4 byte bằng con trỏ thường

Kết quả chạy thật trên ESP32 cho thấy 2 điều:

  • std::unique_ptr có kích thước 4 byte, đúng bằng con trỏ thường. Nó không tốn thêm bộ nhớ hay thời gian chạy, mà vẫn đảm bảo không quên giải phóng. Đã phải cấp phát động thì nên dùng unique_ptr thay vì con trỏ thô.
  • Đối tượng DenSerial chỉ có 4 byte (con trỏ tới bảng hàm ảo), nhưng heap giảm 20 byte. Mỗi lần new, bộ cấp phát tốn thêm vài byte để quản lý khối nhớ. Cấp phát nhiều đối tượng nhỏ trên vi điều khiển rất phí.

Quy tắc mình áp dụng: chỉ cấp phát động một lần trong setup(), giữ suốt chương trình, không bao giờ new/delete trong loop(). Cấp phát và giải phóng liên tục khi chạy nhiều ngày sẽ làm heap bị phân mảnh: tổng RAM trống vẫn còn nhiều nhưng không còn khối liền nào đủ lớn, và chương trình treo sau vài ngày chạy, lỗi rất khó tái hiện.

std::shared_ptr thì nên tránh: nó cần thêm khối điều khiển chứa bộ đếm tham chiếu, và tăng giảm bộ đếm bằng phép toán nguyên tử. Trên vi điều khiển, một đối tượng gần như luôn có đúng một chủ sở hữu rõ ràng, unique_ptr là đủ.

Lỗi make_unique trên Arduino-ESP32

Các bạn để ý ví dụ trên viết std::unique_ptr<DenBao>(new DenLed(2)) chứ không dùng std::make_unique như bài smart pointer. Lý do là framework Arduino-ESP32 mà PlatformIO đang cài biên dịch theo chuẩn C++11 (cờ -std=gnu++11), trong khi make_unique có từ C++14:

#include <Arduino.h>
#include <memory>

struct CamBien { virtual ~CamBien() {} };
struct DHT22 : CamBien {};

void setup()
{
    Serial.begin(115200);
    auto p = std::make_unique<DHT22>();              // C++14
    CamBien* cb = p.get();
    if (dynamic_cast<DHT22*>(cb))                    // can RTTI
        Serial.println("Day la DHT22");
}

void loop() {}

Lỗi biên dịch trên Arduino-ESP32: make_unique cần C++14, dynamic_cast không dùng được với -fno-rtti

Muốn dùng C++17 thì thêm 2 dòng vào platformio.ini. Trình biên dịch GCC 8.4 của toolchain ESP32 hỗ trợ C++17, mình đã build thử thành công với make_unique:

build_unflags = -std=gnu++11
build_flags = -std=gnu++17

Lỗi thứ hai là dynamic_cast: framework biên dịch với -fno-rtti, tắt thông tin kiểu lúc chạy (RTTI) để tiết kiệm Flash, nên dynamic_cast và typeid không dùng được. Nếu cần biết đối tượng thuộc loại nào, thêm một hàm ảo như loai() trả về enum, hoặc tốt hơn là thiết kế lại để không cần hỏi loại.

Những phần nào của C++ nên dùng trên vi điều khiển?

Không phải tính năng nào của C++ cũng phù hợp với chip có vài chục KB RAM. Để có số liệu thật, mình lấy chương trình nháy LED ở trên làm gốc (Flash 269.565 byte), thêm từng tính năng vào hàm setup() rồi build lại với PlatformIO cho ESP32:

Thêm vào chương trình Flash tăng thêm Đánh giá
Một đối tượng Led toàn cục 64 byte Nên dùng
std::unique_ptr + new 304 byte Được, chỉ cấp phát lúc khởi động
std::shared_ptr (make_shared) 508 byte Nên tránh, dùng unique_ptr
std::vector<uint32_t> 1 phần tử 632 byte Hạn chế, cấp phát động bên trong
try/catch + throw std::runtime_error 9.572 byte Thường không dùng
dynamic_cast lỗi biên dịch Không dùng được (-fno-rtti)
  • Class, đóng gói, tham chiếu, template: gần như không tốn thêm gì so với viết bằng C. Đây là phần nên tận dụng nhiều nhất.
  • Hàm ảo: mỗi class có một bảng hàm ảo trong Flash, mỗi đối tượng thêm một con trỏ 4 byte, mỗi lần gọi tra bảng một lần. Trên ESP32 dùng thoải mái, trên chip vài KB RAM thì cân nhắc.
  • Exception: chỉ một khối try/catch đã kéo theo gần 10KB Flash cho cơ chế xử lý ngoại lệ. Arduino-ESP32 vẫn bật exception (-fexceptions), nhưng nhiều toolchain khác như avr-gcc của Arduino Uno biên dịch với -fno-exceptions, viết throw sẽ báo lỗi biên dịch. Trên vi điều khiển, các bạn trả về mã lỗi hoặc bool như hàm pressed() ở trên.
  • STL cấp phát động (vector, string, map): tiện nhưng cấp phát heap ngầm. Nếu dùng thì reserve() trước kích thước tối đa ngay trong setup(), hoặc dùng std::array có kích thước cố định.

Lưu ý: Con số trong bảng là phần Flash tăng thêm khi dùng tính năng đó lần đầu, phần lớn là code thư viện được kéo vào. Dùng lần thứ hai ở chỗ khác sẽ tốn ít hơn nhiều. Các bạn có thể tự đo với project của mình: build trước và sau khi thêm code, so dòng Flash mà PlatformIO in ra.

Đi tiếp từ đây

Kiến thức trong serie dùng ngay được trong các serie vi điều khiển trên blog:

  • Lập trình ESP32 từ A tới Z: framework Arduino là C++, mọi thư viện cảm biến, WiFi, BLE đều là class.
  • Lập trình STM32: HAL viết bằng C, nhưng các bạn có thể bọc lại bằng class như Led, Button trong bài này, và dùng State pattern cho các máy trạng thái nút nhấn, giao thức truyền thông.

Kết

Áp dụng C++ và OOP cho vi điều khiển giúp code gọn hơn, dễ tái sử dụng hơn mà không phải trả giá về hiệu năng, miễn là chọn đúng tính năng: class, tham chiếu, template, hàm ảo thì dùng thoải mái; cấp phát động chỉ làm một lần lúc khởi động và giữ bằng unique_ptr; exception, RTTI, shared_ptr thì tránh. Class Led, Button trong bài này các bạn có thể mang sang mọi project ESP32 khác, chỉ cần đổi số chân.

Thử thách cho các bạn: thêm một đối tượng Led thứ hai nối vào GPIO khác và cho 2 LED nháy với 2 chu kỳ khác nhau, chỉ thêm vài dòng là xong. Tới đây chúng ta đã đi hết serie Lập trình C++ từ A tới Z. Cảm ơn các bạn đã theo dõi, và đừng quên tham gia Hội Anh Em Nghiện Lập trình để cùng chia sẻ kết quả nhé!

Rate this post

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *