為什麼要在 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) |
|---|---|
| RAM | 32 KB ~ 520 KB |
| Flash(程式) | 256 KB ~ 4 MB |
| CPU | 8 ~ 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 嗎?通常不行:
- 中斷 handler 必須極短,呼叫 VM 太慢
- VM 內部狀態(GC、stack)非中斷安全
- 中斷 handler 觸發 GC ➡️ 死路一條
所以模式是:
ISR: 設 flag、把資料丟 ring buffer、結束
main loop:VM 跑著,每 N 條 opcode 檢查 flag → 必要時呼叫 callback這是 MicroPython 的「scheduler」機制:irq 後 schedule 一個 callback,下個 yield point 執行。
中斷的 GC 互動#
GC 進行中時來中斷怎麼辦?選項:
- 整個中斷期間禁 GC:簡單但會延後 GC,可能 OOM
- GC 過程可重入安全:複雜但靈活
- 中斷 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 loop | 4 KB |
| OPCode handlers | 15 KB |
| Value / GC | 8 KB |
| Built-in classes | 12 KB(精簡 String / Array / Hash) |
| RITE loader | 3 KB |
| Symbol table | 動態 |
| Misc | 8 KB |
| Total | ~50 KB |
對比:完整 CPython ~5 MB、Lua ~200 KB、MicroPython ~600 KB。
「精簡到什麼程度」是嵌入式 VM 的核心競爭力之一。
bytecode 預編譯 vs on-device 編譯#
選項:
- 桌面預編譯:在 PC 編好 bytecode 燒進 Flash。最節省(沒有 lexer/parser)
- 裝置上編譯:腳本以源碼存 Flash,需要時在 RAM lex/parse/emit
mruby 在 IoT 場景偏向 1,MicroPython 偏向 2(因為 REPL 是賣點)。
預編譯的好處:parser 不必上裝置 ➡️ ROM 省下幾十 KB。
一些常見錯誤#
- OOM 沒處理:嵌入式 VM 必須在 OOM 時 graceful handle,不能依賴 host malloc 失敗
- 無限迴圈鎖死系統:VM 必須提供 watchdog 介入機制(執行 N 條 opcode 強制 yield)
- Stack 過深:MCU stack 通常 8 KB,C 遞迴 + VM frame 容易爆
- GC 觸發時延遲過長:迴圈中 allocate 的物件不釋放 ➡️ GC 一次清幾百個 ➡️ 影響即時性
實務建議#
寫 / 移植嵌入式 VM 時的思維順序:
- 先確定 ROM/RAM 預算:能用多少?逆推哪些功能要砍
- value 表示:tagged 32-bit 通常足夠,64-bit 通常浪費
- GC:先 RC 跑通,後續視場景換 mark-sweep + 物件池
- bytecode 緊湊:1 byte opcode + 變長 args 是好起點
- 硬體 API 用 native function:不要嘗試在 bytecode 裡操作暫存器
- 中斷不要碰 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 翻成原生碼。