Chương 26. Máy trạng thái hữu hạn cho firmware

Mục tiêu

  • Viết FSM chống rung phím (debounce) bằng enum class + switch, gọi định kỳ, không dùng delay() chặn.
  • Viết FSM phân tích khung giao thức byte-theo-byte, phù hợp dữ liệu đến từ ring buffer (chương 24).
  • Nhận ra khi nào một mớ if/else lồng nhau thực chất là một máy trạng thái chưa được đặt tên — và vì sao đặt tên nó ra giúp code dễ suy luận hơn.

Câu chuyện: if (millis() - last > 50) rải khắp nơi

Firmware ban đầu chống rung phím bằng: đọc chân, nếu khác lần trước thì delay(50) rồi đọc lại. delay() chặn toàn bộ chương trình — trong lúc đó UART không được xử lý, LED không nhấp nháy đúng nhịp. Thay vào đó, một FSM được gọi định kỳ, không chặn (từ vòng lặp chính hoặc timer) tự nhớ nó đang ở đâu trong quá trình chống rung, và chỉ báo "đã nhấn" khi tín hiệu ổn định đủ lâu — không có delay() nào.

Kiến thức

1. FSM chống rung phím

// May trang thai huu han (FSM) chong doi phim (debounce) bang enum class + switch. Duoc goi dinh ky
// (vd tu timer 1ms), khong dung delay() chan - phu hop firmware thoi gian thuc.
#include <cstdint>
#include <cstdio>

enum class ButtonState : std::uint8_t { Released, Debouncing, Pressed, Releasing };

class Button {
public:
    // Goi ham nay MOI lan tick (vd moi 1ms) voi trang thai chan GPIO tho (chua loc).
    void tick(bool raw_pressed) {
        switch (state_) {
            case ButtonState::Released:
                // Dem tu 1: chinh tick nay DA la tick dau tien thay tin hieu nhan.
                if (raw_pressed) { state_ = ButtonState::Debouncing; counter_ = 1; }
                break;
            case ButtonState::Debouncing:
                if (!raw_pressed) { state_ = ButtonState::Released; break; }
                if (++counter_ >= kDebounceTicks) { state_ = ButtonState::Pressed; on_press_edge_ = true; }
                break;
            case ButtonState::Pressed:
                if (!raw_pressed) { state_ = ButtonState::Releasing; counter_ = 1; }
                break;
            case ButtonState::Releasing:
                if (raw_pressed) { state_ = ButtonState::Pressed; break; }
                if (++counter_ >= kDebounceTicks) { state_ = ButtonState::Released; on_release_edge_ = true; }
                break;
        }
    }

    // "Canh" (edge) chi dung MOT lan sau khi doc - tranh xu ly lap lai o vong lap chinh.
    bool consume_press_edge() { const bool v = on_press_edge_; on_press_edge_ = false; return v; }
    bool consume_release_edge() { const bool v = on_release_edge_; on_release_edge_ = false; return v; }
    bool is_pressed() const { return state_ == ButtonState::Pressed || state_ == ButtonState::Releasing; }

private:
    static constexpr std::uint8_t kDebounceTicks = 5;  // 5 tick (vd 5ms neu tick moi 1ms)
    ButtonState state_ = ButtonState::Released;
    std::uint8_t counter_ = 0;
    bool on_press_edge_ = false;
    bool on_release_edge_ = false;
};

int main() {
    Button btn;
    // Gia lap tin hieu GPIO tho, nhieu (rung) trong vai tick dau khi nhan/tha.
    const bool raw_signal[] = {
        false, false, true, false, true, true, true, true, true, true,  // nhan (rung roi on dinh)
        true, true, true, true, true,                                   // giu
        false, true, false, false, false, false, false, false,          // tha (rung roi on dinh)
        false, false,
    };

    for (std::size_t t = 0; t < sizeof(raw_signal) / sizeof(raw_signal[0]); ++t) {
        btn.tick(raw_signal[t]);
        if (btn.consume_press_edge()) std::printf("tick %2zu: PHAT HIEN NHAN (da loc rung)\n", t);
        if (btn.consume_release_edge()) std::printf("tick %2zu: PHAT HIEN THA (da loc rung)\n", t);
    }
}
tick  8: PHAT HIEN NHAN (da loc rung)
tick 21: PHAT HIEN THA (da loc rung)

Bốn trạng thái (Released, Debouncing, Pressed, Releasing) và một bộ đếm là toàn bộ trạng thái cần nhớ. tick() được gọi mỗi chu kỳ cố định (ví dụ mỗi 1 ms từ timer), không đợi, không delay(); các phần khác của firmware (UART, LED, SPI...) chạy song song bình thường giữa các lần gọi. Ví dụ cố tình đưa tín hiệu rung (nhiễu vài tick khi nhấn/thả) để chứng minh: FSM không báo "nhấn" cho tới khi tín hiệu ổn định đủ kDebounceTicks — đúng mục đích chống rung.

consume_press_edge()/consume_release_edge() trả về cạnh (sự kiện xảy ra đúng một lần) chứ không phải trạng thái hiện tại: vòng lặp chính đọc cạnh này để biết "vừa xảy ra sự kiện", tránh xử lý lặp lại cùng một lần nhấn nhiều lần nếu vòng lặp chính chạy chậm hơn tick().

2. FSM phân tích khung giao thức

// FSM phan tich khung giao thuc dang: [STX][LEN][LEN byte du lieu][CHECKSUM][ETX], xu ly TUNG BYTE
// khi no den (tu ring buffer UART - chuong 24), khong can doi ca khung ve mot lan trong bo nho lon.
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstdio>

constexpr std::uint8_t kStx = 0x02;
constexpr std::uint8_t kEtx = 0x03;

enum class ParserState : std::uint8_t { WaitStx, WaitLen, ReadData, WaitChecksum, WaitEtx };

class FrameParser {
public:
    // Tra ve true khi VUA hoan tat mot khung hop le (payload_()/length() doc duoc ngay sau do).
    bool feed(std::uint8_t byte) {
        switch (state_) {
            case ParserState::WaitStx:
                if (byte == kStx) { state_ = ParserState::WaitLen; }
                return false;
            case ParserState::WaitLen:
                if (byte > payload_.size()) { reset("do dai vuot qua bo dem"); return false; }
                len_ = byte;
                idx_ = 0;
                checksum_ = 0;
                state_ = (len_ == 0) ? ParserState::WaitChecksum : ParserState::ReadData;
                return false;
            case ParserState::ReadData:
                payload_[idx_++] = byte;
                checksum_ ^= byte;  // don gian hoa: XOR toan bo byte du lieu (khong phai chuan CRC that)
                if (idx_ == len_) state_ = ParserState::WaitChecksum;
                return false;
            case ParserState::WaitChecksum:
                if (byte != checksum_) { reset("checksum sai"); return false; }
                state_ = ParserState::WaitEtx;
                return false;
            case ParserState::WaitEtx:
                if (byte != kEtx) { reset("thieu ETX"); return false; }
                state_ = ParserState::WaitStx;
                return true;  // khung hoan tat va hop le
        }
        return false;
    }

    std::size_t length() const { return len_; }
    const std::uint8_t* payload() const { return payload_.data(); }

private:
    void reset(const char* reason) {
        std::printf("parser: huy khung (%s), quay ve cho STX\n", reason);
        state_ = ParserState::WaitStx;
    }

    ParserState state_ = ParserState::WaitStx;
    std::array<std::uint8_t, 16> payload_{};
    std::uint8_t len_ = 0;
    std::uint8_t idx_ = 0;
    std::uint8_t checksum_ = 0;
};

int main() {
    FrameParser parser;

    // Khung hop le: STX, LEN=3, 'A','B','C', checksum='A'^'B'^'C', ETX.
    const std::uint8_t good_frame[] = {kStx, 3, 'A', 'B', 'C', static_cast<std::uint8_t>('A' ^ 'B' ^ 'C'), kEtx};
    // Khung loi: checksum sai.
    const std::uint8_t bad_frame[] = {kStx, 2, 'X', 'Y', 0x00, kEtx};
    // Rac truoc mot khung hop le (parser phai bo qua cho den khi thay STX).
    const std::uint8_t noisy_then_good[] = {0xFF, 0xEE, kStx, 1, 'Z', 'Z', kEtx};

    auto run = [&](const std::uint8_t* data, std::size_t n) {
        for (std::size_t i = 0; i < n; ++i) {
            if (parser.feed(data[i])) {
                std::printf("KHUNG HOAN TAT, do dai=%zu, du lieu=\"", parser.length());
                for (std::size_t k = 0; k < parser.length(); ++k) std::putchar(static_cast<char>(parser.payload()[k]));
                std::printf("\"\n");
            }
        }
    };

    run(good_frame, sizeof good_frame);
    run(bad_frame, sizeof bad_frame);
    run(noisy_then_good, sizeof noisy_then_good);
}
KHUNG HOAN TAT, do dai=3, du lieu="ABC"
parser: huy khung (checksum sai), quay ve cho STX
KHUNG HOAN TAT, do dai=1, du lieu="Z"

FrameParser::feed(byte) xử lý từng byte một — đúng cách dữ liệu đến từ ring buffer UART (chương 24): vòng lặp chính lấy từng byte ra khỏi ring buffer và gọi feed, không cần chờ đủ cả khung trong một buffer lớn duy nhất. Năm trạng thái (WaitStx → WaitLen → ReadData → WaitChecksum → WaitEtx) theo đúng cấu trúc khung [STX][LEN][DATA...][CHECKSUM][ETX].

Ba tình huống trong ví dụ minh hoạ tính chịu lỗi của FSM:

  • Khung hợp lệ → hoàn tất, in ra dữ liệu.
  • Checksum sai → reset về WaitStx, không làm hỏng việc phân tích khung tiếp theo.
  • Rác (0xFF, 0xEE) đứng trước một khung hợp lệ → bị bỏ qua ở trạng thái WaitStx cho tới khi gặp đúng kStx, khung sau đó vẫn được phân tích đúng.

Đây là điểm khác biệt quan trọng so với việc parse cả khung một lần bằng memcpy vào một struct: nếu luồng byte bị lẫn rác hoặc mất đồng bộ (rất thường gặp trên UART thực tế do nhiễu, khởi động giữa chừng), FSM tự phục hồi ở byte tiếp theo, trong khi parse nguyên khối dễ đọc sai toàn bộ cấu trúc dữ liệu sau nó.

3. Khi nào một đống if/else là một FSM chưa đặt tên

Dấu hiệu: bạn cần một biến bool/int để "nhớ mình đang ở bước nào" giữa các lần gọi hàm, hoặc giữa các lần lặp. Nếu vậy, đặt tên rõ ràng cho từng trạng thái (enum class) và liệt kê rõ chuyển đổi trong switch gần như luôn dễ đọc và dễ kiểm tra đủ trường hợp hơn — trình biên dịch cảnh báo (-Wswitch) nếu bạn thêm trạng thái mới mà quên xử lý ở một switch nào đó.

Góc nhúng

  • tick()/feed() không cấp phát, không block — an toàn gọi từ ngữ cảnh thời gian thực hoặc thậm chí ISR nếu cần (dù xử lý khung giao thức phức tạp thường tốt hơn nếu để lại vòng lặp chính, xem chương 24 mục 2).
  • Với FSM phức tạp hơn (nhiều chục trạng thái), cân nhắc std::variant (chương 17) khi mỗi trạng thái cần dữ liệu khác nhau đi kèm — switch trên enum class phù hợp khi mọi trạng thái dùng chung một bộ dữ liệu như hai ví dụ trên.
  • Đo kích thước sizeof(Button)/sizeof(FrameParser): toàn bộ trạng thái nằm trong vài byte, không heap — phù hợp làm thành viên tĩnh của driver.

Lỗi thường gặp

  • Chống rung phím bằng delay() chặn cả hệ thống thay vì FSM không chặn gọi định kỳ.
  • Parser giao thức giả định luôn nhận đủ, đúng thứ tự byte — không có đường xử lý khi mất đồng bộ hoặc dữ liệu rác chen vào.
  • switch trên enum class thiếu default và không xử lý hết mọi giá trị — một số trình biên dịch không cảnh báo nếu thêm giá trị enum mới sau này; luôn bật -Wswitch (mặc định có trong -Wall) và xử lý mọi nhánh tường minh thay vì dựa vào default.
  • Trộn logic FSM với logic xuất I/O trực tiếp (gọi gpio_write ngay trong switch) khiến khó unit test FSM một mình trên host (chương 29 quay lại điểm này).

Bài tập

  1. Thêm trạng thái LongPress vào Button: nếu giữ nút quá 1000 tick, phát sinh một cạnh riêng on_long_press_edge_.
  2. Sửa FrameParser để hỗ trợ LEN = 0 (khung không có dữ liệu) — kiểm tra code hiện tại đã xử lý đúng trường hợp này chưa (gợi ý: xem nhánh WaitLen).
  3. Viết một bộ test thủ công (không cần framework) gọi feed() với một khung bị cắt cụt giữa chừng (dữ liệu kết thúc đột ngột) — parser có kẹt ở trạng thái giữa chừng mãi không, và nên xử lý thế nào (gợi ý: timeout dựa trên số tick không nhận thêm byte)?
  4. Giải thích bằng lời vì sao FSM byte-theo-byte phù hợp hơn với dữ liệu từ ring buffer UART so với việc chờ đủ N byte rồi memcpy một lần.

Tóm tắt

  • FSM gọi định kỳ, không chặn, là cách chuẩn để chống rung phím và các tác vụ "chờ ổn định" khác trong firmware thời gian thực.
  • FSM byte-theo-byte cho phân tích giao thức tự nhiên khớp với luồng dữ liệu từ ring buffer, và tự phục hồi khi gặp rác/mất đồng bộ.
  • Khi thấy mình cần một biến "nhớ trạng thái" giữa các lần gọi, đó là dấu hiệu nên viết một FSM có tên rõ ràng thay vì if/else rải rác.