2026年初收到一個任務:一位有軟體研發經驗的人但不是韌體工程師,能不能透過 AI 踏入嵌入式研發?

關於這件事,我跟老闆們討論滿多次,我認為每個人背景/技術不同,提問的品質先天上就有差異,它會導致 AI 一開始的走向有很大的變化,雖然未來仍有可能收斂到一樣的成果,但不免覺得很浪費時間。

碎念一下,滿像一個人從國小到大學的教育過程,一開始沒有受到良好的教育機會(忙於幫忙家裡生計) 或是家庭背景下對學習這件事並不重視,以至於學習時間和態度並不積極,過程成效差異是很大的,最終在高中甚至大學有所領悟時,有機會一口氣補回來,但那種補回來的成本大,且成功補回來的機率其實並不高,現實生活中,很有可能停止升學。出社會後,那些人走過上述的繞彎路線的人,不約而同的都表達 "學生時代還是專心學習是最大的投資報酬“ ,畢竟不是每個人可走回來那條路,學生時代是僅有最接近大家都平等學習的狀態的。當然,也有很多不是公平的資源,例如探討下去就會面對學區房價等議題了 XD

回到不同背景的研發者去觸發 AI 工作也是類似的道理,最終還是該回歸用有效的方式做事,派有經驗的人使喚AI,還是勝過無經驗者。時間是永遠稀缺的資源,AI 依舊放大強者的產出。

在此就先定義這本次實驗者的軟體研發的經驗有哪些:

  • Web Developer (前端/後端)
  • App Developer (iOS/Android)
  • DevOps (AWS/GCP/DBA/SA)

就這樣開啟了這個小挑戰,以引導 AI 開發的兼職流程進行,以工作天來說,大約每3~5個工作天,可以去克服一項硬體元件的初始化,如 HDMI-RX, USB Driver, WIFI module 等,後來因為 USB Driver 穩定性轉到 RT-Thread framework ,最終完成了基本的雛形。在此先筆記當初把玩 FreeRTOS 的足跡。

當初,先挑個 FreeRTOS 試試水溫,想說他是一個很簡約的 RTOS 。

從 DevOps 的思維出發,著重在系統的可觀測性的規劃,預計系統運行後,至少會跑起兩個任務,一個是每秒的狀態輸出,另一個是簡易的 console 介面,例如 > log on 跟 > log off ,開機完不斷印出資訊,輸入 log off 就立刻關閉;Web Developer 思維則是會導入 Test-driven Development (TDD) 跟 Spec-driven development (SDD) 角度,任何進展時會著重可測試性,而 App Developer 的部分,會多了 UI main thread 的概念,引導 AI 著重規劃平行處理事件的方向或 Event loop 等思維。最後,為了讓 AI 可以反覆在任何環境研發,就會包了類似 Docker 的環境,甚至讓其他同事接手也能快速上手,類似 CI/CD 等思維方便任務的交接。

log 不直接印,而是先丟進 queue,由 console 那個 task 統一輸出,跟 server 上 syslog / journald 的思維一樣:誰產生 log 都不會被慢速的輸出卡住。

為了不用板子也能跑,這裡用 FreeRTOS 的 POSIX port:每個 task 是一條 pthread,tick 用系統計時訊號模擬,整個 RTOS 就是一個普通程式,UART 的輸出、輸入換成 stdout、stdin。

於是乎,撰寫最小可運行的 main.c,整個目錄就三個檔案:

$ tree 01-log-shell
01-log-shell
├── FreeRTOSConfig.h
├── main.c
└── Makefile

main.c:

/*
 * 01-log-shell: the first thing to build on a new embedded system --
 * make it observable. Runs on the FreeRTOS POSIX port (Linux / macOS / Docker).
 *
 *   Heartbeat task : posts one status line per second
 *   LogShell task  : the only writer of the "UART" (stdout); drains the log
 *                    queue and reads commands from stdin (the "UART RX")
 *
 * On a real board, uart_write() / uart_read_nonblock() would talk to UART
 * registers; everything else is the same FreeRTOS code.
 */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdarg.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>

#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"

#define FW_VERSION "01-log-shell 0.1 (FreeRTOS " tskKERNEL_VERSION_NUMBER ", POSIX port)"

/* ------------------------------------------------------------------ */
/* "UART": stdout / stdin on the simulator                              */
/* ------------------------------------------------------------------ */
static void uart_write(const char *s)
{
    size_t n = strlen(s);
    while (n > 0) {
        ssize_t w = write(STDOUT_FILENO, s, n);
        if (w < 0) { if (errno == EINTR) continue; return; }   /* the tick is a signal */
        s += w; n -= (size_t)w;
    }
}

/* Non-blocking: a task must not block inside a host syscall on the POSIX
 * port, so poll like firmware polls a UART RX FIFO.
 * Returns the byte, -1 if nothing is waiting, -2 on end of input (Ctrl+D). */
static int uart_read_nonblock(void)
{
    unsigned char c;
    ssize_t r = read(STDIN_FILENO, &c, 1);
    if (r == 1) return (int)c;
    return (r == 0) ? -2 : -1;
}

/* Simulator only: let Ctrl+C and `docker stop` end the program.
 * Two traps, both hit while writing this:
 * - The POSIX port unblocks every signal in its task threads, so a signal
 *   lands on whichever task thread is running, not on a helper thread.
 * - Inside Docker the program is PID 1, and the kernel drops any signal PID 1
 *   has no handler for (SIG_DFL). Ctrl+C, `docker kill -s INT` and the
 *   SIGTERM from `docker stop` were all silently ignored.
 * A real handler fixes both: it runs on whatever thread gets the signal, and
 * PID 1 with a handler does receive it. */
static void on_stop_signal(int sig)
{
    static const char msg[] = "\n[sim] stopped by signal\n";
    (void)sig;
    (void)!write(STDOUT_FILENO, msg, sizeof msg - 1);   /* async-signal-safe */
    _exit(0);
}

static void install_stop_handlers(void)
{
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_stop_signal;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGINT, &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);
    sigaction(SIGHUP, &sa, NULL);
}

/* ------------------------------------------------------------------ */
/* Log service: producers post, one consumer prints                     */
/* ------------------------------------------------------------------ */
#define LOG_MSG_MAX 96
typedef struct {
    const char *module;
    TickType_t  tick;
    char        text[LOG_MSG_MAX];
} log_msg_t;

static QueueHandle_t s_log_q;
static volatile int  s_log_on = 1;
static volatile unsigned long s_log_posted, s_log_dropped;

/* Never waits: a slow UART must not stall the caller. */
static int log_post(const char *module, const char *fmt, ...)
{
    log_msg_t m;
    va_list ap;
    m.module = module;
    m.tick = xTaskGetTickCount();
    va_start(ap, fmt);
    vsnprintf(m.text, sizeof m.text, fmt, ap);
    va_end(ap);
    if (xQueueSend(s_log_q, &m, 0) == pdTRUE) { s_log_posted++; return 1; }
    s_log_dropped++;
    return 0;
}

/* ------------------------------------------------------------------ */
/* Shell                                                                */
/* ------------------------------------------------------------------ */
static void cmd_status(void)
{
    char b[160];
    snprintf(b, sizeof b, "[status] tick=%lu log=%s posted=%lu dropped=%lu\n",
             (unsigned long)xTaskGetTickCount(), s_log_on ? "on" : "off",
             s_log_posted, s_log_dropped);
    uart_write(b);
}

static void shell_exec(char *line)
{
    while (*line == ' ') line++;
    if (*line == '\0') return;
    if (strcmp(line, "help") == 0) {
        uart_write("commands: help | version | status | log on | log off | ! | quit\n");
    } else if (strcmp(line, "version") == 0) {
        uart_write(FW_VERSION "\n");
    } else if (strcmp(line, "status") == 0) {
        cmd_status();
    } else if (strcmp(line, "log on") == 0) {
        s_log_on = 1; uart_write("[shell] log on\n");
    } else if (strcmp(line, "log off") == 0 || strcmp(line, "!") == 0) {
        s_log_on = 0; uart_write("[shell] log off\n");
    } else if (strcmp(line, "quit") == 0) {
        uart_write("[shell] bye\n");
        exit(0);                       /* simulator only: a board never exits */
    } else {
        uart_write("[shell] unknown command, try: help\n");
    }
}

/* ------------------------------------------------------------------ */
/* Tasks                                                                */
/* ------------------------------------------------------------------ */
static void heartbeat_task(void *arg)
{
    (void)arg;
    unsigned long n = 0;
    for (;;) {
        log_post("SYS", "alive #%lu (free heap %u B)", ++n,
                 (unsigned)xPortGetFreeHeapSize());
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

static void log_shell_task(void *arg)
{
    (void)arg;
    char line[64];
    size_t len = 0;
    log_msg_t m;

    for (;;) {
        /* 1. drain logs (wait up to 10 ms so the loop is not a busy spin) */
        while (xQueueReceive(s_log_q, &m, pdMS_TO_TICKS(10)) == pdTRUE) {
            if (s_log_on) {
                char b[160];
                snprintf(b, sizeof b, "[T=%lu][%s] %s\n",
                         (unsigned long)m.tick, m.module, m.text);
                uart_write(b);
            }
        }
        /* 2. read whatever arrived on the "UART RX" */
        for (int c; (c = uart_read_nonblock()) != -1; ) {
            if (c == -2) {                      /* Ctrl+D: input closed */
                uart_write("[shell] end of input, bye\n");
                exit(0);
            }
            if (c == '\r' || c == '\n') {
                line[len] = '\0'; shell_exec(line); len = 0;
            } else if (len < sizeof line - 1) {
                line[len++] = (char)c;
            }
        }
    }
}

int main(void)
{
    install_stop_handlers();           /* simulator only: Ctrl+C / docker stop */

    /* stdin non-blocking: the POSIX port forbids blocking host calls in tasks */
    fcntl(STDIN_FILENO, F_SETFL, fcntl(STDIN_FILENO, F_GETFL) | O_NONBLOCK);

    uart_write("[RTOS] boot\n");
    uart_write(FW_VERSION "\n");
    uart_write("type 'help' and Enter; quit, Ctrl+D or Ctrl+C to leave\n");

    s_log_q = xQueueCreate(32, sizeof(log_msg_t));
    configASSERT(s_log_q != NULL);

    xTaskCreate(heartbeat_task, "Heartbeat", configMINIMAL_STACK_SIZE, NULL, 2, NULL);
    xTaskCreate(log_shell_task, "LogShell",  configMINIMAL_STACK_SIZE, NULL, 3, NULL);

    vTaskStartScheduler();             /* never returns */
    return 0;
}

FreeRTOSConfig.h:

/* FreeRTOSConfig.h for the POSIX (simulator) port.
 * Runs FreeRTOS as a normal process on Linux / macOS: each task is a pthread,
 * the tick is a host timer signal. Only the settings this example needs. */
#ifndef FREERTOS_CONFIG_H
#define FREERTOS_CONFIG_H

#define configUSE_PREEMPTION                    1
#define configUSE_IDLE_HOOK                     0
#define configUSE_TICK_HOOK                     0
#define configTICK_RATE_HZ                      ( 1000 )
#define configMAX_PRIORITIES                    ( 5 )
#define configMINIMAL_STACK_SIZE                ( ( unsigned short ) 4096 )
#define configTOTAL_HEAP_SIZE                   ( ( size_t ) ( 256 * 1024 ) )
#define configMAX_TASK_NAME_LEN                 ( 16 )
#define configTICK_TYPE_WIDTH_IN_BITS           TICK_TYPE_WIDTH_32_BITS
#define configIDLE_SHOULD_YIELD                 1
#define configUSE_MUTEXES                       1
#define configQUEUE_REGISTRY_SIZE               0
#define configUSE_TRACE_FACILITY                0
#define configCHECK_FOR_STACK_OVERFLOW          0
#define configSUPPORT_DYNAMIC_ALLOCATION        1
#define configSUPPORT_STATIC_ALLOCATION         0
#define configUSE_TIMERS                        0

#define INCLUDE_vTaskDelay                      1
#define INCLUDE_vTaskDelete                     1
#define INCLUDE_xTaskGetTickCount               1

#include <assert.h>
#define configASSERT( x )                       assert( x )

#endif /* FREERTOS_CONFIG_H */

Makefile:

# 01-log-shell on the FreeRTOS POSIX port.
#   make            # fetch ../FreeRTOS-Kernel (once) and build ./log-shell
#   ./log-shell     # run; type help / status / log off / quit
#   make BIN=app-linux   # another output name; resource/docker/build.sh uses it
#                        # so a macOS binary and a Linux binary can share the folder
BIN          ?= log-shell
FREERTOS_TAG ?= V11.3.2
# The kernel is shared by every example under resource/freertos/
FREERTOS_DIR ?= ../FreeRTOS-Kernel
PORT_DIR     := $(FREERTOS_DIR)/portable/ThirdParty/GCC/Posix

SRCS := main.c \
        $(FREERTOS_DIR)/tasks.c $(FREERTOS_DIR)/queue.c $(FREERTOS_DIR)/list.c \
        $(PORT_DIR)/port.c $(PORT_DIR)/utils/wait_for_event.c \
        $(FREERTOS_DIR)/portable/MemMang/heap_4.c

CFLAGS += -O2 -g -Wall -I. -I$(FREERTOS_DIR)/include -I$(PORT_DIR) -I$(PORT_DIR)/utils -pthread
LDFLAGS += -pthread

$(BIN): $(FREERTOS_DIR)/tasks.c $(SRCS) FreeRTOSConfig.h
	$(CC) $(CFLAGS) -o $@ $(SRCS) $(LDFLAGS)

$(FREERTOS_DIR)/tasks.c:
	git clone --depth 1 --branch $(FREERTOS_TAG) https://github.com/FreeRTOS/FreeRTOS-Kernel.git $(FREERTOS_DIR)

clean:
	rm -f log-shell app-linux

.PHONY: clean

接著規劃用 Docker 來編譯跟運行,一個容器編譯、另一個容器運行,都 mount 當下目錄(FreeRTOS-Kernel 第一次編譯時會自動 clone 到上一層,給之後的範例共用):

# 容器 1:用 gcc:13 編譯,產生 01-log-shell/app-linux
docker run --rm -v "$PWD":/src -w /src/01-log-shell gcc:13 make BIN=app-linux

# 容器 2:用乾淨的 debian 運行(跟 gcc:13 同樣是 bookworm,glibc 一致)
docker run --rm -it --name rtos-01-log-shell \
  -v "$PWD":/src -w /src/01-log-shell debian:bookworm-slim ./app-linux

跑起來:

[RTOS] boot
01-log-shell 0.1 (FreeRTOS V11.1.0+, POSIX port)
type 'help' and Enter; quit, Ctrl+D or Ctrl+C to leave
[T=1][SYS] alive #1 (free heap 159552 B)
[T=1001][SYS] alive #2 (free heap 159552 B)
status
[status] tick=1661 log=on posted=2 dropped=0
log off
[shell] log off
quit
[shell] bye

幾個小提醒:

  • 模擬的 tick 不等於真實時間(實測 Docker 約 0.77 倍、macOS 約 0.67 倍),拿來學 API 沒問題,拿來量時間就不準
  • POSIX port 裡的 task 不能卡在系統呼叫上,所以 stdin 設成 non-blocking 輪詢,剛好跟韌體輪詢 UART RX FIFO 是同一件事
  • 第一版在 Docker 裡按 Ctrl+C 關不掉,連 docker stop 都要等 10 秒被強制砍掉。原因是程式在容器裡是 PID 1,Linux 會丟掉 PID 1 沒有處理函式的訊號;加上 POSIX port 會讓 task 執行緒收所有訊號。補一個 sigaction 處理 SIGINT / SIGTERM 就解決了
  • macOS 上也可以直接 make && ./log-shell,不用 Docker