為什麼要在 MCU 上跑 VM#

MCU(ESP32、STM32、Arduino)原生是寫 C/C++ 的世界,為什麼還要塞個語言 VM 進去?

動機場景
動態更新韌體已 deploy,但邏輯需要 OTA 改
業務邏輯熱載IoT 設備配置複雜的規則
開發效率業務邏輯用腳本寫比 C 快
沙箱第三方插件不能無限制存取硬體
教育給學生友善的入門(micro:bit 跑 MicroPython)

代表 VM:

  • Lua / eLua:嵌入式社群最久的語言之一
  • MicroPython:Python 子集 + 大量硬體 API
  • CircuitPython:Adafruit fork 的 MicroPython,更友善
  • mruby / mruby-c:Ruby 子集,VM 極小
  • NodeMCU(Lua):ESP8266 早期主流
  • JerryScript:Samsung 的 ES5.1 引擎,跑 IoT.js
  • Espruino:JavaScript on STM32

受限環境的本質#

MCU 的物理限制:

資源典型值(ESP32 / STM32F4 / ATmega328)
RAM32 KB ~ 520 KB
Flash(程式)256 KB ~ 4 MB
CPU8 ~ 240 MHz,無 cache 或極小
功耗預算μA ~ mA 等級
即時性需求毫秒甚至微秒

VM 設計必須對應每一條限制。

取捨 1:bytecode 大小 vs 執行速度#

PC 上 8-byte register VM 顯得緊湊;MCU 上仍可能太大。

mruby IREP 經典做法:緊湊編碼 + 變長指令。一個 int literal 可能 1 byte(0~7)、2 byte(< 256)、4 byte(< 64K)、…

代價:每次 fetch 要看 opcode 變體決定參數長度,多分支。但 ROM 省下很多。

取捨 2:value 表示#

PC:NaN-boxing 效能最佳。MCU:很多 32-bit 架構沒 FPU,double 軟模擬就慢,NaN-boxing 失去意義。

替代:tagged 32-bit value ── 高 2 bit 是 tag、低 30 bit 是 payload。

typedef uint32_t value_t;

#define TAG_MASK   0x3
#define TAG_INT    0x1
#define TAG_OBJ    0x0

#define IS_INT(v)  (((v) & TAG_MASK) == TAG_INT)
#define AS_INT(v)  ((int32_t)(v) >> 2)
#define INT_VAL(i) ((value_t)((i) << 2) | TAG_INT)

8-byte 變 4-byte,stack 佔用減半。

取捨 3:GC 策略#

mark-sweep 在小 heap 上碎片化是真實災難

allocate 8 → allocate 16 → allocate 8 → free 中間
heap: [8B][hole 16B][8B]
allocate 16B → 找不到連續 16B → OOM(其實有 16B 但碎了)

選項:

引用計數 + 弱循環處理#

struct Object {
    uint8_t  rc;            // 8-bit 通常夠(256 個 ref 已多)
    uint8_t  type;
    uint16_t flags;
    ...
};

void inc_ref(Object *o) { if (o->rc < 255) o->rc++; }
void dec_ref(Object *o) {
    if (o->rc == 255) return;       // saturated,由 cycle collector 處理
    if (--o->rc == 0) free_object(o);
}

mruby/c 採類似策略:8-bit RC、簡易 cycle 處理。

Tracing GC + 物件池#

不用 malloc/free,把 heap 切成預先分配的固定大小 slot:

[slot 1: 32B][slot 2: 32B][slot 3: 32B]...   // 全部 32B

物件大小不一致時,分多級池(24B、48B、96B、…)。GC 只把 slot 標記為 free,無 fragmentation。

代表:MicroPython 的 GC 用變形的 mark-sweep 配合 16-byte 對齊區塊,避免碎片。

取捨 4:Heap vs Stack 配置#

MCU 上 stack 與 heap 通常各分一塊:

+---------+  ← 高位址
| Stack   |  ← 向下增長
|         |
+---------+
| (free)  |
+---------+
| Heap    |  ← 向上增長
|         |
+---------+
| BSS     |
+---------+
| Data    |
+---------+
| Text    |  ← 通常在 Flash
+---------+  ← 低位址

若 stack 與 heap 撞在一起 ➡️ stack overflow ➡️ 記憶體損毀。所以兩者上限要保守設,常見模式:

  • 把 VM stack 配在 RAM 的固定位置,限制深度(如 64 frame)
  • 把 heap 限制在 30~50% RAM

取捨 5:bytecode 在 Flash 還是 RAM?#

如果 bytecode 可從 Flash 直接執行 ── 省下 RAM。但要:

  • bytecode 是位置無關(無自我修改)
  • VM dispatch 不依賴 bytecode 的可寫性

絕大多數設計都把 bytecode 放 Flash,常數池視情況:

  • 純常數放 Flash
  • 需要動態 intern 的字串放 RAM

mruby 的 RITE 格式設計上 bytecode 可以 mmap 直接執行 ── 連 PC 都 freeze 在 Flash 範圍。

取捨 6:JIT? 一律否#

理由(如第十一章末):

  • ROM 不夠放編譯器
  • W+X 限制:MCU 通常有,但要 IRAM、有特殊 bus
  • RAM 不夠放 native code buffer
  • workload 通常 IO bound,不是 CPU bound

唯一的例外是高階 MCU(Cortex-M7 高階、ESP32 雙核)跑長計算 workload,可能值得 baseline JIT。例:mruby 的 mrb-aot 把腳本編成原生 ARM code 燒進 Flash ── 嚴格說是 AOT 不是 JIT。

與硬體互動:FFI#

VM 必須能控制 GPIO、I2C、SPI、ADC:

# MicroPython
from machine import Pin, ADC

led = Pin(2, Pin.OUT)
led.value(1)

adc = ADC(Pin(34))
val = adc.read()

實作:每個硬體 driver 寫 C function,註冊為 VM 的 native function。VM 呼叫時直接進 C 程式碼操作硬體暫存器。

中斷與 VM#

VM 在跑 bytecode,硬體中斷觸發 ── 中斷 handler 想呼叫 VM API 嗎?通常不行

  1. 中斷 handler 必須極短,呼叫 VM 太慢
  2. VM 內部狀態(GC、stack)非中斷安全
  3. 中斷 handler 觸發 GC ➡️ 死路一條

所以模式是:

ISR:     設 flag、把資料丟 ring buffer、結束
main loop:VM 跑著,每 N 條 opcode 檢查 flag → 必要時呼叫 callback

這是 MicroPython 的「scheduler」機制:irq 後 schedule 一個 callback,下個 yield point 執行。

中斷的 GC 互動#

GC 進行中時來中斷怎麼辦?選項:

  1. 整個中斷期間禁 GC:簡單但會延後 GC,可能 OOM
  2. GC 過程可重入安全:複雜但靈活
  3. 中斷 handler 不配置記憶體:實務最常見

對 MCU 級 workload,方案 3 最實際:把所有可能配置的工作延到 main loop 處理。

功耗管理#

MCU 大量時間在睡眠(μA 級)。VM 必須配合:

# MicroPython
import machine
machine.deepsleep(1000)   # 睡 1 秒

實作:呼叫 deepsleep,VM 把當前 state 寫進 NVM ➡️ MCU 進入 deep sleep ➡️ 醒來時 reload state。或更簡單:deepsleep 不返回,重啟整個系統,由韌體主回 main 函式。

體積拆解:mruby/c 的 ~50 KB ROM 怎麼來#

元件概略大小
Dispatch loop4 KB
OPCode handlers15 KB
Value / GC8 KB
Built-in classes12 KB(精簡 String / Array / Hash)
RITE loader3 KB
Symbol table動態
Misc8 KB
Total~50 KB

對比:完整 CPython ~5 MB、Lua ~200 KB、MicroPython ~600 KB。

「精簡到什麼程度」是嵌入式 VM 的核心競爭力之一。

bytecode 預編譯 vs on-device 編譯#

選項:

  1. 桌面預編譯:在 PC 編好 bytecode 燒進 Flash。最節省(沒有 lexer/parser)
  2. 裝置上編譯:腳本以源碼存 Flash,需要時在 RAM lex/parse/emit

mruby 在 IoT 場景偏向 1,MicroPython 偏向 2(因為 REPL 是賣點)。

預編譯的好處:parser 不必上裝置 ➡️ ROM 省下幾十 KB。

一些常見錯誤#

  1. OOM 沒處理:嵌入式 VM 必須在 OOM 時 graceful handle,不能依賴 host malloc 失敗
  2. 無限迴圈鎖死系統:VM 必須提供 watchdog 介入機制(執行 N 條 opcode 強制 yield)
  3. Stack 過深:MCU stack 通常 8 KB,C 遞迴 + VM frame 容易爆
  4. GC 觸發時延遲過長:迴圈中 allocate 的物件不釋放 ➡️ GC 一次清幾百個 ➡️ 影響即時性

實務建議#

寫 / 移植嵌入式 VM 時的思維順序:

  1. 先確定 ROM/RAM 預算:能用多少?逆推哪些功能要砍
  2. value 表示:tagged 32-bit 通常足夠,64-bit 通常浪費
  3. GC:先 RC 跑通,後續視場景換 mark-sweep + 物件池
  4. bytecode 緊湊:1 byte opcode + 變長 args 是好起點
  5. 硬體 API 用 native function:不要嘗試在 bytecode 裡操作暫存器
  6. 中斷不要碰 VM:用 ring buffer + flag 模式

小結#

嵌入式 VM 的設計與桌面 VM 完全不同:

  • 沒 JIT、沒大型 GC、沒充裕 stack
  • value 表示要 4-byte、heap 要對抗碎片、bytecode 要 ROM 友善
  • 硬體互動透過 native function、中斷透過 ring buffer
  • 體積比效能重要,可預測比吞吐重要

它是 PL 工程的另一面:在每個位元都珍貴的環境裡,重新檢視「真正必要的是什麼」。

語言實作 12 章到此結束。下個值得讀的方向:寫一個自己的玩具 VM(推薦 Crafting Interpreters);讀 V8 / JSC 的 blog 跟現代化進展;學 LLVM 或寫一個簡單 compiler 把 bytecode 翻成原生碼。