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