Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 16.322 phút đọc

Máy trạng thái

Sau bài này bạn sẽ làm được

  • Mô hình hóa một bài toán bằng máy trạng thái
  • Cài bảng chuyển trạng thái bằng enum và mảng hai chiều
  • So sánh bảng với chuỗi if lồng nhau
  • Kiểm chứng máy trạng thái bằng bộ kiểm thử

Máy trạng thái là cách mô hình hóa mọi chương trình phản ứng với sự kiện: giao thức mạng, trình phân tích cú pháp, giao diện người dùng, thiết bị nhúng. Với enum, C cho bạn cách cài nó vừa gọn vừa an toàn.

#Máy trạng thái là gì

Máy trạng thái
Một mô hình gồm tập trạng thái hữu hạn, tập sự kiện, và quy tắc cho biết ở trạng thái nào gặp sự kiện nào thì chuyển sang trạng thái nào.
Bốn trạng thái của một kết nối. Mỗi mũi tên là một quy tắc chuyển, và nhãn trên mũi tên là sự kiện gây ra nó.
khai-bao.c
typedef enum {
    TT_CHO,
    TT_DANG_NOI,
    TT_DA_NOI,
    TT_LOI,
    TT_SO_LUONG,
} TrangThai;

typedef enum {
    SK_BAT_DAU,
    SK_OK,
    SK_HONG,
    SK_DUNG,
    SK_SO_LUONG,
} SuKien;
Thành phầnTrong ví dụ này
Tập trạng tháiCHO, DANG_NOI, DA_NOI, LOI
Trạng thái đầuCHO
Tập sự kiệnBAT_DAU, OK, HONG, DUNG
Quy tắc chuyểnBảng 4 nhân 4, mỗi ô là trạng thái kế tiếp
Hành độngViệc cần làm khi một bước chuyển xảy ra

#Bảng chuyển trạng thái

bang.c
/* Bảng hai chiều: dòng là trạng thái hiện tại, cột là sự kiện. */
static const TrangThai BANG[TT_SO_LUONG][SK_SO_LUONG] = {
    /*                     SK_BAT_DAU     SK_OK         SK_HONG    SK_DUNG  */
    [TT_CHO]      = { [SK_BAT_DAU] = TT_DANG_NOI,
                      [SK_OK]      = TT_CHO,
                      [SK_HONG]    = TT_CHO,
                      [SK_DUNG]    = TT_CHO },

    [TT_DANG_NOI] = { [SK_BAT_DAU] = TT_DANG_NOI,
                      [SK_OK]      = TT_DA_NOI,
                      [SK_HONG]    = TT_LOI,
                      [SK_DUNG]    = TT_CHO },

    [TT_DA_NOI]   = { [SK_BAT_DAU] = TT_DA_NOI,
                      [SK_OK]      = TT_DA_NOI,
                      [SK_HONG]    = TT_LOI,
                      [SK_DUNG]    = TT_CHO },

    [TT_LOI]      = { [SK_BAT_DAU] = TT_DANG_NOI,
                      [SK_OK]      = TT_LOI,
                      [SK_HONG]    = TT_LOI,
                      [SK_DUNG]    = TT_CHO },
};

/* Toàn bộ máy trạng thái gọn trong một hàm bốn dòng. */
TrangThai chuyen(TrangThai tt, SuKien sk)
{
    if (tt < 0 || tt >= TT_SO_LUONG) return TT_LOI;
    if (sk < 0 || sk >= SK_SO_LUONG) return tt;

    return BANG[tt][sk];
}
dung.c
#include <stdio.h>

static const char *TT_TEN[TT_SO_LUONG] = {
    [TT_CHO]      = "CHO",
    [TT_DANG_NOI] = "DANG_NOI",
    [TT_DA_NOI]   = "DA_NOI",
    [TT_LOI]      = "LOI",
};

static const char *SK_TEN[SK_SO_LUONG] = {
    [SK_BAT_DAU] = "BAT_DAU",
    [SK_OK]      = "OK",
    [SK_HONG]    = "HONG",
    [SK_DUNG]    = "DUNG",
};

int main(void)
{
    TrangThai tt = TT_CHO;

    SuKien chuoi[] = { SK_BAT_DAU, SK_OK, SK_HONG, SK_BAT_DAU, SK_OK, SK_DUNG };

    for (size_t i = 0; i < sizeof chuoi / sizeof chuoi[0]; ++i) {
        TrangThai truoc = tt;

        tt = chuyen(tt, chuoi[i]);

        printf("%-10s + %-8s -> %s\n",
               TT_TEN[truoc], SK_TEN[chuoi[i]], TT_TEN[tt]);
    }

    return 0;
}
terminal
./may-trang-thai
CHO        + BAT_DAU  -> DANG_NOI
DANG_NOI   + OK       -> DA_NOI
DA_NOI     + HONG     -> LOI
LOI        + BAT_DAU  -> DANG_NOI
DANG_NOI   + OK       -> DA_NOI
DA_NOI     + DUNG     -> CHO

#Thêm hành động vào mỗi bước chuyển

Bảng ở trên chỉ nói trạng thái kế tiếp. Trong thực tế, mỗi bước chuyển thường phải làm gì đó: mở kết nối, ghi log, gửi gói tin.

hanh-dong.c
/* Mỗi ô là một cặp: trạng thái kế tiếp và hành động cần chạy. */
typedef int (*HanhDong)(void *ngu_canh);

typedef struct {
    TrangThai tiep;
    HanhDong  lam;      /* NULL nếu không cần làm gì */
} BuocChuyen;

static int mo_ket_noi(void *nc);
static int gui_du_lieu(void *nc);
static int dong_ket_noi(void *nc);
static int ghi_log_loi(void *nc);

static const BuocChuyen BANG[TT_SO_LUONG][SK_SO_LUONG] = {
    [TT_CHO] = {
        [SK_BAT_DAU] = { TT_DANG_NOI, mo_ket_noi },
        [SK_OK]      = { TT_CHO,      NULL },
        [SK_HONG]    = { TT_CHO,      NULL },
        [SK_DUNG]    = { TT_CHO,      NULL },
    },
    [TT_DANG_NOI] = {
        [SK_BAT_DAU] = { TT_DANG_NOI, NULL },
        [SK_OK]      = { TT_DA_NOI,   gui_du_lieu },
        [SK_HONG]    = { TT_LOI,      ghi_log_loi },
        [SK_DUNG]    = { TT_CHO,      dong_ket_noi },
    },
    [TT_DA_NOI] = {
        [SK_BAT_DAU] = { TT_DA_NOI,   NULL },
        [SK_OK]      = { TT_DA_NOI,   gui_du_lieu },
        [SK_HONG]    = { TT_LOI,      ghi_log_loi },
        [SK_DUNG]    = { TT_CHO,      dong_ket_noi },
    },
    [TT_LOI] = {
        [SK_BAT_DAU] = { TT_DANG_NOI, mo_ket_noi },
        [SK_OK]      = { TT_LOI,      NULL },
        [SK_HONG]    = { TT_LOI,      NULL },
        [SK_DUNG]    = { TT_CHO,      dong_ket_noi },
    },
};

/* Trạng thái của máy gom vào một struct, không dùng biến toàn cục. */
typedef struct {
    TrangThai tt;
    void     *ngu_canh;
} May;

void may_khoi_tao(May *m, void *ngu_canh)
{
    m->tt       = TT_CHO;
    m->ngu_canh = ngu_canh;
}

int may_su_kien(May *m, SuKien sk)
{
    if (m == NULL)                   return -1;
    if (m->tt < 0 || m->tt >= TT_SO_LUONG) return -1;
    if (sk < 0 || sk >= SK_SO_LUONG) return -1;

    const BuocChuyen *b = &BANG[m->tt][sk];

    if (b->lam != NULL) {
        int kq = b->lam(m->ngu_canh);

        if (kq != 0) {          /* hành động thất bại: chuyển sang trạng thái lỗi */
            m->tt = TT_LOI;
            return kq;
        }
    }

    m->tt = b->tiep;

    return 0;
}

#So với chuỗi if lồng nhau

Nhiều biến cờ và chuỗi if
int da_ket_noi = 0;
int dang_noi   = 0;
int bi_loi     = 0;

void xu_ly_su_kien(int sk)
{
    if (sk == SK_BAT_DAU) {
        if (!da_ket_noi && !dang_noi) {
            dang_noi = 1;
            mo_ket_noi();
        } else if (bi_loi) {
            bi_loi   = 0;
            dang_noi = 1;
            mo_ket_noi();
        }
    } else if (sk == SK_OK) {
        if (dang_noi) {
            dang_noi   = 0;
            da_ket_noi = 1;
        } else if (da_ket_noi) {
            gui_du_lieu();
        }
    } else if (sk == SK_HONG) {
        if (dang_noi || da_ket_noi) {
            dang_noi   = 0;
            da_ket_noi = 0;
            bi_loi     = 1;
            ghi_log_loi();
        }
    }
    /* ... và còn nữa ... */
}
Một biến trạng thái và một bảng
TrangThai tt = TT_CHO;

void xu_ly_su_kien(SuKien sk)
{
    tt = chuyen(tt, sk);
}
Tiêu chíChuỗi if với biến cờBảng chuyển
Số trạng thái biểu diễn được2 mũ số cờ, phần lớn vô nghĩaĐúng số trạng thái thật
Trạng thái không hợp lệCó thể xảy ra, ví dụ vừa đang nối vừa đã nốiKhông thể
Thêm một trạng tháiRà lại mọi câu ifThêm một dòng vào bảng
Thêm một sự kiệnThêm một nhánh và rà lại mọi trạng tháiThêm một cột vào bảng
Xem tổng quan hành viPhải đọc hết hàmNhìn bảng là thấy
Kiểm thử đầy đủRất khó, không biết bao nhiêu tổ hợpDễ, đúng số ô của bảng

#Kiểm chứng máy trạng thái

test.c
#include <assert.h>
#include <stdio.h>

static int so_luot = 0;

#define KT(bieu_thuc)                                          \
    do {                                                       \
        ++so_luot;                                             \
        if (!(bieu_thuc)) {                                    \
            printf("HONG dong %d: %s\n", __LINE__, #bieu_thuc); \
            return 1;                                          \
        }                                                      \
    } while (0)

/* Kiểm thử 1: mọi ô của bảng đều cho ra trạng thái hợp lệ */
static int kt_bang_hop_le(void)
{
    for (int t = 0; t < TT_SO_LUONG; ++t)
        for (int s = 0; s < SK_SO_LUONG; ++s) {
            TrangThai tiep = BANG[t][s];

            KT(tiep >= 0 && tiep < TT_SO_LUONG);
        }

    return 0;
}

/* Kiểm thử 2: mọi trạng thái đều tới được từ trạng thái đầu */
static int kt_toi_duoc(void)
{
    int toi_duoc[TT_SO_LUONG] = { 0 };
    int hang_doi[TT_SO_LUONG];
    int dau = 0, cuoi = 0;

    toi_duoc[TT_CHO] = 1;
    hang_doi[cuoi++] = TT_CHO;

    while (dau < cuoi) {
        int t = hang_doi[dau++];

        for (int s = 0; s < SK_SO_LUONG; ++s) {
            int tiep = BANG[t][s];

            if (!toi_duoc[tiep]) {
                toi_duoc[tiep]   = 1;
                hang_doi[cuoi++] = tiep;
            }
        }
    }

    for (int t = 0; t < TT_SO_LUONG; ++t)
        KT(toi_duoc[t]);

    return 0;
}

/* Kiểm thử 3: từ mọi trạng thái, sự kiện DUNG luôn đưa về CHO */
static int kt_dung_luon_ve_cho(void)
{
    for (int t = 0; t < TT_SO_LUONG; ++t)
        KT(BANG[t][SK_DUNG] == TT_CHO);

    return 0;
}

/* Kiểm thử 4: một chuỗi sự kiện cụ thể cho ra đúng dãy trạng thái mong đợi */
static int kt_chuoi(void)
{
    SuKien    vao[] = { SK_BAT_DAU, SK_OK,     SK_HONG, SK_DUNG };
    TrangThai ra[]  = { TT_DANG_NOI, TT_DA_NOI, TT_LOI,  TT_CHO };

    TrangThai tt = TT_CHO;

    for (size_t i = 0; i < sizeof vao / sizeof vao[0]; ++i) {
        tt = chuyen(tt, vao[i]);
        KT(tt == ra[i]);
    }

    return 0;
}

/* Kiểm thử 5: giá trị lạ không làm sập chương trình */
static int kt_gia_tri_la(void)
{
    KT(chuyen((TrangThai)999, SK_OK) == TT_LOI);
    KT(chuyen(TT_CHO, (SuKien)999)   == TT_CHO);
    KT(chuyen((TrangThai)-1, SK_OK)  == TT_LOI);

    return 0;
}

int main(void)
{
    if (kt_bang_hop_le())      return 1;
    if (kt_toi_duoc())         return 1;
    if (kt_dung_luon_ve_cho()) return 1;
    if (kt_chuoi())            return 1;
    if (kt_gia_tri_la())       return 1;

    printf("Tat ca %d luot kiem thu deu qua\n", so_luot);

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra -g -fsanitize=address,undefined test.c -o test && ./test
Tat ca 31 luot kiem thu deu qua

Kiểm tra thêm lúc biên dịch

/* Bảo đảm kích thước bảng khớp với số hằng enum */
_Static_assert(sizeof BANG / sizeof BANG[0] == TT_SO_LUONG,
               "So dong cua bang khong khop so trang thai");

_Static_assert(sizeof BANG[0] / sizeof BANG[0][0] == SK_SO_LUONG,
               "So cot cua bang khong khop so su kien");

Sinh sơ đồ từ bảng

sinh-so-do.c
/* In ra định dạng DOT để vẽ sơ đồ bằng Graphviz */
void in_so_do(void)
{
    printf("digraph {\n");
    printf("  rankdir=LR;\n");

    for (int t = 0; t < TT_SO_LUONG; ++t)
        for (int s = 0; s < SK_SO_LUONG; ++s) {
            int tiep = BANG[t][s];

            if (tiep != t)      /* bỏ qua các cạnh tự vòng cho gọn */
                printf("  %s -> %s [label=\"%s\"];\n",
                       TT_TEN[t], TT_TEN[tiep], SK_TEN[s]);
        }

    printf("}\n");
}
terminal
./may-trang-thai --so-do | dot -Tpng -o so-do.png
# Sơ đồ luôn khớp với mã, vì cả hai sinh từ một bảng
xdg-open so-do.png

Đây là lợi ích cuối cùng của mô hình bảng: hành vi của chương trình là dữ liệu, nên bạn kiểm tra được nó, vẽ được nó, và thậm chí đọc được nó từ file cấu hình lúc chạy.

Tự làm thử

  1. Vẽ máy trạng thái của đèn giao thông ba màu ra giấy, rồi cài nó bằng bảng chuyển.
  2. Cài máy trạng thái kết nối trong bài, chạy chuỗi sự kiện và in ra từng bước chuyển.
  3. Bỏ phần kiểm tra biên trong hàm chuyen, truyền một giá trị lạ và chạy dưới -fsanitize=address.
  4. Thêm trạng thái TT_DANG_DONG vào máy, xác nhận bảng và bảng tên tự lớn theo.
  5. Cài bảng có hành động, viết bốn hàm hành động in ra tên của chúng.
  6. Viết đủ năm loại kiểm thử trong bài, cố tình để trống một ô rồi xem kiểm thử nào bắt được.
  7. Cài hàm sinh sơ đồ DOT, vẽ ra ảnh và đối chiếu với hình trong bài.
  8. Cài trình phân tích số thực bằng máy trạng thái: nhận dạng chuỗi có dạng dấu, phần nguyên, dấu chấm, phần lẻ.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Máy trạng thái gồm tập trạng thái, tập sự kiện và quy tắc chuyển. Enum biểu diễn hai tập đầu, mảng hai chiều biểu diễn quy tắc.
  • Dùng khởi tạo có chỉ định cho bảng, và đặt trạng thái đầu ở giá trị 0 để ô bỏ trống có ý nghĩa hợp lý.
  • Luôn kiểm tra biên trước khi tra bảng, vì sự kiện thường đến từ bên ngoài chương trình.
  • Một biến trạng thái thay được nhiều biến cờ, và nó làm những trạng thái sai trở nên không biểu diễn được.
  • Số ô của bảng là hữu hạn nên kiểm thử toàn diện làm được. Năm loại kiểm thử trong bài áp dụng cho mọi máy trạng thái.