Skip to content

Design Patterns

In software engineering, a software design pattern is a general, reusable solution to a commonly occurring problem within a given context in software design.

Top 10 design pattern trong embedded

Danh sách dưới đây chọn từ bảng khoảng 60 pattern chia theo 8 nhóm (truy cập phần cứng, mô hình thực thi, đồng bộ, bộ nhớ, giao tiếp, an toàn, tổ chức code, kiến trúc). Tiêu chí xếp hạng là mức độ gặp thường xuyên trong code thực tế của firmware MCU/RTOS và Linux kernel/driver, không phải mức độ "hay" về lý thuyết. Đây là đánh giá chủ quan.

Trong C nhúng, phần lớn các pattern dựa trên ba kỹ thuật: biến static trong module, struct chứa con trỏ hàm, và hàng đợi sự kiện. Các khối tô vàng trong sơ đồ là thành phần chính của pattern.

Hạng Pattern Nhóm Vì sao gặp nhiều
1 Hardware Proxy Truy cập phần cứng Mọi driver đều cần một chỗ duy nhất chạm thanh ghi
2 Interrupt (ISR ngắn + defer) Truy cập phần cứng Cách chuẩn để phản ứng với thiết bị mà không tốn CPU
3 Ring buffer Bộ nhớ và buffer Cầu nối ISR ↔ task cho UART, log, luồng mẫu
4 State machine Mô hình thực thi Giao thức, trình tự bật nguồn, chế độ hoạt động
5 Virtual API (bảng con trỏ hàm) Tổ chức code Cơ chế đứng sau Bridge, Strategy, State; khắp nơi trong kernel
6 Layered Kiến trúc Kiến trúc mặc định: HAL/BSP, driver, middleware, ứng dụng
7 Event-driven Mô hình thực thi Gom mọi nguồn bất đồng bộ về một vòng lặp
8 Observer Giao tiếp Một sự kiện, nhiều bên quan tâm (notifier, callback)
9 Critical Section, Mutex và Priority Inheritance Đồng bộ Bất kỳ dữ liệu nào dùng chung giữa task và ISR
10 Watchdog An toàn và tin cậy Thiết bị chạy không người trông phải tự phục hồi

Đề cử thêm (rất phổ biến nhưng ngoài top 10): Super Loop (MCU nhỏ không OS), Opaque handle, Static Allocation và Pool, Singleton, Callback có context (void *ctx), Goto cleanup, Strategy.

1. Hardware Proxy

Gói toàn bộ thao tác thanh ghi của một thiết bị sau một API, phần còn lại của code không chạm register. Bên trong thường dùng struct volatile đặt lên địa chỉ base (kiểu CMSIS).

Hardware Proxy

Mọi module chỉ gọi API của uart_proxy.c. Chi tiết bit mask, thứ tự ghi và timeout nằm gọn ở một chỗ, nên đổi phần cứng hay sửa lỗi timing chỉ động vào một file.

/* uart_proxy.h: phần còn lại của code chỉ thấy API này */
int uart_init(uint32_t baud);
int uart_send(const uint8_t *b, size_t n);

/* uart_proxy.c: file duy nhất chạm thanh ghi */
typedef struct {
    volatile uint32_t SR, DR, BRR, CR;
} uart_regs_t;
#define UART1 ((uart_regs_t *)0x40011000u)

int uart_send(const uint8_t *b, size_t n) {
    for (size_t i = 0; i < n; i++) {
        uint32_t t = TIMEOUT;
        while (!(UART1->SR & SR_TXE))   /* chờ thanh ghi phát rỗng */
            if (--t == 0) return -ETIMEDOUT;
        UART1->DR = b[i];
    }
    return 0;
}

Biến thể gần: Hardware Adapter bọc một driver có sẵn để khớp interface mà ứng dụng mong đợi (đổi chip không sửa tầng trên), còn Proxy tự chạm register.

Đã gặp: header CMSIS của các MCU Cortex-M, readl()/writel() gói trong các hàm helper của driver Linux.

2. Interrupt (ISR ngắn + defer)

Thiết bị chủ động báo CPU bằng ngắt. ISR chỉ làm phần tối thiểu (đọc dữ liệu, xoá cờ, đẩy vào hàng đợi) rồi giao phần còn lại cho task.

Interrupt

void USART1_IRQHandler(void) {        /* ISR: vài µs */
    uint8_t b = UART1->DR;            /* đọc DR cũng xoá cờ RXNE */
    rb_push(b);                       /* ring buffer, xem mục 3 */
    BaseType_t woken = pdFALSE;
    vTaskNotifyGiveFromISR(rx_task_handle, &woken);
    portYIELD_FROM_ISR(woken);
}

void rx_task(void *arg) {             /* phần nặng chạy ở task */
    uint8_t b;
    for (;;) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        while (rb_pop(&b))
            nmea_parse(b);
    }
}

Chọn cái nào: sự kiện thưa mà cần phản ứng nhanh thì dùng Interrupt. Sự kiện rất dày, hoặc cần độ trễ và jitter thấp nhất, thì dùng Polling, như PMD của DPDK poll liên tục trên core cô lập.

Đã gặp: top half/bottom half của Linux (request_threaded_irq(), tasklet, workqueue), NAPI của driver mạng.

3. Ring buffer

Lựa chọn chuẩn cho UART RX, log, luồng mẫu. Bản SPSC (single-producer single-consumer) chọn dung lượng là lũy thừa của 2 để dùng mask (& MASK) thay phép chia lấy dư, và an toàn không cần khóa khi có đúng một bên ghi head, một bên ghi tail.

Ring buffer

#define RB_SIZE 16                 /* phải là lũy thừa của 2 */
#define RB_MASK (RB_SIZE - 1)

typedef struct {
    uint8_t buf[RB_SIZE];
    volatile uint32_t head;        /* producer viết */
    volatile uint32_t tail;        /* consumer viết */
} ring_t;

static ring_t rb;

/* Producer: chạy trong ISR, chỉ đụng vào head */
bool rb_push(uint8_t x) {
    uint32_t next = (rb.head + 1) & RB_MASK;
    if (next == rb.tail) return false;   /* đầy */
    rb.buf[rb.head] = x;
    rb.head = next;                      /* publish sau khi ghi xong */
    return true;
}

/* Consumer: chạy trong main loop, chỉ đụng vào tail */
bool rb_pop(uint8_t *x) {
    if (rb.tail == rb.head) return false; /* rỗng */
    *x = rb.buf[rb.tail];
    rb.tail = (rb.tail + 1) & RB_MASK;
    return true;
}

Lưu ý: trên MCU đa lõi hoặc có cache, cần memory barrier để bên đọc không thấy head tăng trước khi dữ liệu tại buf[head] được ghi xong. Với nhiều producer hoặc nhiều consumer, phải dùng CAS như rte_ring của DPDK hoặc quay lại dùng khóa.

Đã gặp: kfifo của Linux kernel, ring buffer UART trong hầu hết HAL của MCU, rte_ring của DPDK.

4. State machine

Khi cùng một sự kiện nhưng phản ứng khác nhau tùy chế độ hệ thống, if-else với cờ sẽ rối rất nhanh. FSM làm các trạng thái và bước chuyển tường minh.

Ví dụ đồng bộ GNSS: từ FREERUN, bắt được GNSS thì sang LOCKED. Mất GNSS thì vào HOLDOVER. Hết thời gian holdover thì về FREERUN và báo alarm.

State machine

typedef enum { S_FREE, S_LOCK, S_HOLD, S_N } st_t;
typedef enum { E_LOCK, E_LOST, E_TMO, E_N } ev_t;
typedef struct { st_t next; void (*act)(void); } tr_t;

/* Toàn bộ hành vi nằm trong một bảng.
   Transition hợp lệ luôn phải có act (không cần làm gì
   thì dùng hàm rỗng), vì act == NULL nghĩa là ô trống. */
static const tr_t fsm[S_N][E_N] = {
  [S_FREE][E_LOCK] = { S_LOCK, alarm_clear },
  [S_LOCK][E_LOST] = { S_HOLD, ho_timer_start },
  [S_HOLD][E_LOCK] = { S_LOCK, ho_timer_stop },
  [S_HOLD][E_TMO]  = { S_FREE, alarm_raise },
};

static st_t cur = S_FREE;

void fsm_dispatch(ev_t e) {
    if (e >= E_N) return;  /* event ngoài bảng */
    const tr_t *t = &fsm[cur][e];
    if (!t->act) return;   /* ô trống: bỏ qua event */
    t->act();
    cur = t->next;
}

Ba cách cài đặt:

  • switch-case, hợp với FSM nhỏ.
  • Bảng như trên: nhìn là thấy hết, dễ review.
  • Mỗi state là một struct handler riêng: đây mới đúng là State pattern của GoF, hợp khi mỗi state có nhiều logic.

Khi nhiều state xử lý chung một event, nên nâng lên hierarchical state machine (UML statechart). Hai thói quen đáng giữ: mỗi event phải xử lý xong mới nhận event tiếp theo (run-to-completion), và log mọi lần chuyển trạng thái (state, event, next), công cụ debug giá trị nhất khi lỗi xảy ra ngoài hiện trường.

Đã gặp: PHY state machine trong drivers/net/phy/phy.c, servo của linuxptp (UNLOCKED → JUMP → LOCKED), Zephyr SMF, framework QP.

5. Virtual API (bảng con trỏ hàm)

Một struct chứa con trỏ tới bảng hàm const: code gọi chỉ biết interface, phần cài đặt được chọn lúc khởi tạo. Đây là cơ chế đứng sau Bridge, Strategy và State. Ví dụ dưới là Bridge: tách "làm gì" (driver cảm biến) khỏi "chạy trên cái gì" (I²C hay SPI, MCU hay Linux).

Virtual API

/* Interface: cách truy cập bus */
typedef struct {
    int (*rd)(void *h, uint8_t reg, uint8_t *b, size_t n);
    int (*wr)(void *h, uint8_t reg, const uint8_t *b, size_t n);
} bus_ops_t;

/* Driver không biết bus là gì */
typedef struct {
    const bus_ops_t *bus;
    void *h;            /* fd i2c-dev, SPI handle... */
} tsensor_t;

int tsensor_read(tsensor_t *s, int32_t *t) {
    uint8_t raw[2];
    int rc = s->bus->rd(s->h, REG_TEMP, raw, 2);
    if (rc == 0) *t = convert(raw);
    return rc;
}

/* Ghép lúc khởi tạo, driver không đổi dòng nào */
tsensor_t a = { &linux_i2c_ops, &i2c_fd };
tsensor_t b = { &stm32_spi_ops, &hspi1 };

Cùng một kỹ thuật, khác mục đích:

Pattern Đổi cái gì Ai đổi, khi nào
Strategy Thuật toán Code gọi/cấu hình, lúc chạy
State Hành vi theo trạng thái FSM tự chuyển khi có event
Bridge Nền tảng bên dưới Lúc build/khởi tạo, rồi cố định

Lưu ý:

  • Bảng ops nên const để nằm trong .rodata/flash, không bị ghi đè.
  • Gọi qua con trỏ hàm không inline được. Ở hot path nên gọi theo lô, giống API burst của DPDK. Nếu nền tảng cố định lúc build, có thể chọn cài đặt lúc link (bus_linux.c hay bus_stm32.c) để khỏi tốn chi phí gọi gián tiếp.

Đã gặp: struct file_operations, regmap (driver gọi regmap_read(), bên dưới là regmap-i2c/spi/mmio), ethdev của DPDK, cpufreq governor.

6. Layered

HAL/BSP ở dưới, rồi driver và middleware, trên cùng là logic nghiệp vụ. Kiến trúc phổ biến nhất trong nhúng.

Layered

app/          main.c, sync_service.c          ← logic nghiệp vụ
middleware/   nmea_parser.c, ptp_msg.c
drivers/      gnss_ublox.c, eeprom_at24.c
hal/          hal_uart_stm32.c, hal_i2c_stm32.c  ← chỉ tầng này chạm thanh ghi

Lưu ý:

  • Không gọi vượt tầng, tầng dưới không #include header của tầng trên. Khi tầng dưới cần báo lên (ví dụ có dữ liệu mới) thì dùng callback hoặc Observer.
  • Giữ tầng HAL mỏng: đổi board chỉ phải viết lại HAL/BSP, và thay HAL bằng bản giả là chạy được logic phía trên ngay trên PC.
  • Khi cần tách lỏng hơn nữa, có thể chuyển sang Hexagonal (Ports & Adapters): lõi tự định nghĩa interface nó cần, phần cứng và OS chỉ là adapter cắm vào.

Đã gặp: STM32Cube (HAL/LL), AUTOSAR (Application, RTE, Basic Software với MCAL ở dưới cùng), Linux (subsystem core, driver, bus).

7. Event-driven

Các nguồn bất đồng bộ (ngắt, timer, message) chỉ sinh event và đẩy vào hàng đợi. Một vòng lặp lấy event ra và xử lý lần lượt. ISR rất ngắn, không cần busy-polling, CPU được ngủ khi hết việc.

Event-driven

typedef struct { uint8_t sig; uint32_t data; } event_t;
static event_t q[16];
static volatile uint8_t head, tail;

void UART_IRQHandler(void) {    /* ISR chỉ đẩy event */
    q[head] = (event_t){ EV_RX, UART->DR };
    head = (head + 1) % 16;     /* bỏ check đầy cho gọn */
}

int main(void) {
    for (;;) {
        __disable_irq();
        if (head == tail) {     /* hết việc → ngủ */
            __WFI();
            __enable_irq();
            continue;
        }
        event_t e = q[tail];
        tail = (tail + 1) % 16;
        __enable_irq();
        dispatch(&e);           /* VD: gọi fsm_dispatch() */
    }
}

Lưu ý: phải tắt ngắt trước khi kiểm tra queue rồi mới gọi __WFI(). Nếu không, một ngắt đến đúng lúc giữa bước kiểm tra và bước ngủ sẽ bị treo đến ngắt kế tiếp (lost wakeup). Tắt ngắt trước vẫn an toàn vì WFI vẫn được đánh thức bởi ngắt pending dù PRIMASK đang chặn.

Khi ghép Event-driven với State machine (mỗi module có queue riêng, thread riêng, một FSM, và chỉ giao tiếp bằng event), ta được Active Object (xem phần Concurrent Design Pattern bên dưới). Các module không chia sẻ dữ liệu nên gần như không cần mutex. Đây là kiến trúc của framework QP (Miro Samek).

Đã gặp: trên Linux, vòng lặp tương đương là epoll với timerfd/eventfd; esp_event của ESP-IDF.

8. Observer

Nguồn phát sự kiện không cần biết ai đang nghe, nên thêm bên nghe mới không phải sửa code nguồn. Ví dụ cảm biến nhiệt báo cho quạt, alarm và logger.

Observer

typedef void (*temp_cb)(int temp_c, void *ctx);

#define MAX_SUBS 8
struct sub { temp_cb cb; void *ctx; };
static struct sub subs[MAX_SUBS]; /* tĩnh, không malloc */
static int n_subs;

int temp_subscribe(temp_cb cb, void *ctx) {
    if (n_subs == MAX_SUBS) return -1;
    subs[n_subs++] = (struct sub){ cb, ctx };
    return 0;
}

/* Subject: không biết ai đang nghe */
static void temp_publish(int t) {
    for (int i = 0; i < n_subs; i++)
        subs[i].cb(t, subs[i].ctx);
}

/* Các module tự đăng ký lúc init */
temp_subscribe(fan_on_temp, &fan_ctl);
temp_subscribe(alarm_on_temp, &alarm_mgr);

Lưu ý:

  • Callback chạy trong ngữ cảnh của bên publish. Nếu gọi từ ISR thì mọi observer cũng chạy trong ISR, phải ngắn, không block. Đây là lý do kernel tách atomic notifier chain và blocking notifier chain.
  • Một observer chậm làm chậm tất cả. Khi đó nên publish qua hàng đợi (chuyển sang Event-driven).
  • Nên đăng ký xong trong giai đoạn init, tránh subscribe/unsubscribe khi đang notify.
  • Thêm một bus trung gian để bên phát và bên nghe không cần biết nhau thì thành Publish–Subscribe (zbus, D-Bus, DDS).

Đã gặp: notifier chain của kernel (register_netdevice_notifier, reboot notifier), zbus của Zephyr, esp_event của ESP-IDF.

9. Critical Section, Mutex và Priority Inheritance

Nhiều task hoặc ISR cùng đụng vào một dữ liệu thì phải bảo vệ:

  • Critical Section: tắt ngắt trong một đoạn thật ngắn, dùng khi dữ liệu chung với ISR.
  • Mutex / Guarded Call: bắt mọi truy cập đi qua một hàm có mutex bên trong, dùng giữa các task.
  • Priority Inheritance: chống priority inversion khi các task khác priority dùng chung mutex.
/* MCU: tắt ngắt thật ngắn, giữ trạng thái cũ để lồng nhau được */
static inline uint32_t crit_enter(void) {
    uint32_t primask = __get_PRIMASK();
    __disable_irq();
    return primask;
}
static inline void crit_exit(uint32_t primask) { __set_PRIMASK(primask); }

uint32_t s = crit_enter();
shared_counter++;          /* đọc-sửa-ghi biến dùng chung với ISR */
crit_exit(s);

Priority inversion: task ưu tiên thấp L đang giữ mutex thì task ưu tiên cao H cần cùng mutex phải chờ, điều này chấp nhận được. Vấn đề xảy ra khi task ưu tiên trung bình M, không liên quan gì đến mutex, chen vào chạy trước L, khiến H phải chờ cả M lẫn L. Priority inheritance tạm nâng priority của L lên bằng H trong lúc L giữ khóa.

Priority inheritance

#include <pthread.h>

pthread_mutex_t mtx;
pthread_mutexattr_t attr;

void mutex_init_with_inheritance(void) {
    pthread_mutexattr_init(&attr);
    /* PTHREAD_PRIO_INHERIT: khi task cao chờ, task đang giữ khóa
       được tạm nâng priority lên bằng task đang chờ cao nhất */
    pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT);
    pthread_mutex_init(&mtx, &attr);
}

/* Cách khác: PTHREAD_PRIO_PROTECT (priority ceiling): mutex có
   sẵn một ceiling priority, ai lock cũng chạy tạm ở mức đó, đơn
   giản hơn để phân tích worst-case nhưng tốn priority hơn mức cần. */

Lưu ý: FreeRTOS có priority inheritance trong mutex nhưng không có trong binary semaphore; Linux có cả hai giao thức qua pthread_mutexattr_setprotocol. Dùng semaphore thường để bảo vệ tài nguyên dùng chung giữa các task khác priority thì priority inversion vẫn có thể xảy ra ngầm. Để chống deadlock khi phải giữ nhiều khóa, khóa theo một thứ tự cố định (lockdep của kernel kiểm tra thứ tự khóa lúc chạy).

Đã gặp: sự cố Mars Pathfinder 1997 là ví dụ kinh điển của priority inversion không được xử lý, sau đó khắc phục bằng cách bật priority inheritance trên VxWorks.

10. Watchdog

Nếu không được "kick" đúng hạn thì watchdog phần cứng reset hệ thống. Cách làm tốt hơn watchdog đơn giản (chỉ có một chỗ gọi wdt_kick()) là có một task giám sát nhận heartbeat từ từng task, chỉ kick khi tất cả còn sống, tránh trường hợp một task đã treo nhưng task khác vẫn vô tình kick hộ.

Watchdog

#define N_TASKS 3
static volatile uint32_t heartbeat[N_TASKS];
static volatile uint32_t g_tick;

/* Mỗi task tự báo còn sống, gọi định kỳ trong vòng lặp của mình */
void task_heartbeat(int id) { heartbeat[id] = g_tick; }

/* Task giám sát: chạy độc lập, priority cao, chu kỳ ngắn hơn watchdog HW */
void monitor_task(void) {
    for (;;) {
        bool all_alive = true;
        for (int i = 0; i < N_TASKS; i++) {
            if (g_tick - heartbeat[i] > MAX_SILENT_TICKS) {
                all_alive = false;   /* task i im lặng quá lâu */
                break;
            }
        }
        if (all_alive) wdt_kick();   /* chỉ kick khi mọi task còn sống */
        /* không kick nếu có task treo -> watchdog HW hết hạn -> reset */
        sleep_ms(MONITOR_PERIOD_MS);
    }
}

Lưu ý: watchdog chỉ đảm bảo hệ không bị treo cứng hoàn toàn; nó không phát hiện được lỗi logic khiến một task chạy sai nhưng vẫn "sống". Kết hợp thêm Sanity Check trên dữ liệu đầu ra mới bắt được lớp lỗi đó.

Đã gặp: /dev/watchdog và WatchdogSec của systemd trên Linux, IWDG/WWDG trên STM32.

Tài liệu nên đọc

  • Real-Time Design Patterns (Bruce Powel Douglass, 2002): catalog đầy đủ nhất về kiến trúc, đồng thời, bộ nhớ, tài nguyên, phân tán và an toàn.
  • Design Patterns for Embedded Systems in C (Bruce Powel Douglass, 2010): bản thực hành bằng C, sát chủ đề nhất.
  • Practical UML Statecharts in C/C++ (Miro Samek): đi sâu vào State machine và Event-driven.
  • Patterns for Time-Triggered Embedded Systems (Michael Pont, 2001): kiến trúc time-triggered.
  • Fluent C (Christopher Preschern, O'Reilly, 2022): idiom C.
  • Embedded C Programming Design Patterns (Martin Schröder, 2023): GoF và concurrency bằng C.
  • Test-Driven Development for Embedded C (James Grenning, 2011): test seam và thay thế lúc link.

Software Design Patterns

https://en.wikipedia.org/wiki/Software_design_pattern

Event Handling

event-handling

Reactor

The Reactor pattern allows event-driven applications to demultiplex and dispatch synchronously and serially service requests that are received simultaneously from one or more clients.

Preactor

The Proactor pattern allows event-driven applications to demultiplex and dispatch service requests in an efficient asynchronous way.

Concurent Design Pattern

Refer Vol2

https://github.com/ppizarro/coursera/tree/master/POSA/Books/Pattern-Oriented%20Software%20Architecture

Active Object

Lavender R. Greg, Schmidt Douglas C, "Active Object - an Object Behavioral Pattern for Concurrent Programming"

active-obj

Thread Pool

also called a replicated workers or worker-crew model

thread_pool

Messaging System