给 PLC 写一个字节码虚拟机:STVM 虚拟机架构设计
一、为什么 2026 年还要自研一个虚拟机
STVM 要解决的问题很具体:让客户的 ST(IEC 61131-3 结构化文本)程序跑在资源有限的嵌入式网关上,且不把维护成本留给自己。
替代方案曾经是"MatIEC 编译成 C 代码 + GCC 交叉编译成 .so + 设备加载",这条路的痛点几乎每个做过的团队都踩过:
| 问题 | 描述 |
|---|---|
| 二进制兼容性 | 客户换一次 Linux 内核版本,所有 .so 重新编译 |
| 部署复杂 | 依赖 Docker 镜像,嵌入式设备内存根本不够 |
| 实时性不可控 | .so 里的死循环无法被打断,只能靠 Watchdog 整体重启 |
| 客户锁定弱 | .so 可被反编译,客户容易整体迁移到竞品 |
| 维护成本高 | GCC 版本、Glibc 符号问题制造源源不断的工单 |
字节码虚拟机把这些问题的答案收敛成一句话:把"执行"从编译工具链里剥离出来 。ST 源码在 PC 端编译成自研 .bytecode 格式,设备端只需要一个几百 KB 的解释器------
- 字节码不依赖操作系统版本,一次编译,到处运行;
- VM 核心内存占用控制在 MB 级,适配 256MB RAM 的网关;
- 解释循环里埋了指令配额,任何死循环都在 VM 的掌控之内;
- 字节码格式私有且带校验,比裸 .so 多一层锁定;
- 实现全部 C++11 自研,零 GPL 传染。
本文聚焦虚拟机本体的架构:内存模型、指令集、执行循环、PLC 扫描语义和调度器。编译器前端(AST、类型检查、代码生成)是另一个话题,这里只交代与 VM 的接口边界。
二、总体架构:一条编译期与运行期的分界线
#mermaid-svg-U3aYWmLZc19midUG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-U3aYWmLZc19midUG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-U3aYWmLZc19midUG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-U3aYWmLZc19midUG .error-icon{fill:#552222;}#mermaid-svg-U3aYWmLZc19midUG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-U3aYWmLZc19midUG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-U3aYWmLZc19midUG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-U3aYWmLZc19midUG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-U3aYWmLZc19midUG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-U3aYWmLZc19midUG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-U3aYWmLZc19midUG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-U3aYWmLZc19midUG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-U3aYWmLZc19midUG .marker.cross{stroke:#333333;}#mermaid-svg-U3aYWmLZc19midUG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-U3aYWmLZc19midUG p{margin:0;}#mermaid-svg-U3aYWmLZc19midUG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-U3aYWmLZc19midUG .cluster-label text{fill:#333;}#mermaid-svg-U3aYWmLZc19midUG .cluster-label span{color:#333;}#mermaid-svg-U3aYWmLZc19midUG .cluster-label span p{background-color:transparent;}#mermaid-svg-U3aYWmLZc19midUG .label text,#mermaid-svg-U3aYWmLZc19midUG span{fill:#333;color:#333;}#mermaid-svg-U3aYWmLZc19midUG .node rect,#mermaid-svg-U3aYWmLZc19midUG .node circle,#mermaid-svg-U3aYWmLZc19midUG .node ellipse,#mermaid-svg-U3aYWmLZc19midUG .node polygon,#mermaid-svg-U3aYWmLZc19midUG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-U3aYWmLZc19midUG .rough-node .label text,#mermaid-svg-U3aYWmLZc19midUG .node .label text,#mermaid-svg-U3aYWmLZc19midUG .image-shape .label,#mermaid-svg-U3aYWmLZc19midUG .icon-shape .label{text-anchor:middle;}#mermaid-svg-U3aYWmLZc19midUG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-U3aYWmLZc19midUG .rough-node .label,#mermaid-svg-U3aYWmLZc19midUG .node .label,#mermaid-svg-U3aYWmLZc19midUG .image-shape .label,#mermaid-svg-U3aYWmLZc19midUG .icon-shape .label{text-align:center;}#mermaid-svg-U3aYWmLZc19midUG .node.clickable{cursor:pointer;}#mermaid-svg-U3aYWmLZc19midUG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-U3aYWmLZc19midUG .arrowheadPath{fill:#333333;}#mermaid-svg-U3aYWmLZc19midUG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-U3aYWmLZc19midUG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-U3aYWmLZc19midUG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-U3aYWmLZc19midUG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-U3aYWmLZc19midUG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-U3aYWmLZc19midUG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-U3aYWmLZc19midUG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-U3aYWmLZc19midUG .cluster text{fill:#333;}#mermaid-svg-U3aYWmLZc19midUG .cluster span{color:#333;}#mermaid-svg-U3aYWmLZc19midUG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-U3aYWmLZc19midUG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-U3aYWmLZc19midUG rect.text{fill:none;stroke-width:0;}#mermaid-svg-U3aYWmLZc19midUG .icon-shape,#mermaid-svg-U3aYWmLZc19midUG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-U3aYWmLZc19midUG .icon-shape p,#mermaid-svg-U3aYWmLZc19midUG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-U3aYWmLZc19midUG .icon-shape .label rect,#mermaid-svg-U3aYWmLZc19midUG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-U3aYWmLZc19midUG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-U3aYWmLZc19midUG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-U3aYWmLZc19midUG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 嵌入式设备
PC / 开发环境
📄 ST / IEC 源码
🔧 编译器前端
词法 + 语法 + 语义检查
字节码生成器
.bytecode
魔数 + CRC32 校验
Loader
校验 + 映射
VM 执行器
取指·译码·执行·配额
调度器
PLC 顺序扫描
系统调用层
SVC 表 + FB 状态
宿主环境
GPIO / 串口 / 现场 IO
分界线两边的契约只有一个:.bytecode 文件格式 + 指令集 。前端的词法、语法、类型检查怎么实现(哪怕推倒重来),只要产出的字节码不变,设备端一行代码都不用动。这也是字节码方案最容易被低估的好处------前后端可以独立演进。
三、虚拟机模型:栈式解释器与五块内存
3.1 运行时内存全景
VM_Context 是整个执行器的世界,拆开看是五块性质完全不同的内存:
| 内存区 | 生命周期 | 分配时机 | 语义 |
|---|---|---|---|
| 代码区 | 随程序加载 | 加载期,只读 | 指令流,ip 在此游走 |
| 全局变量区 | 跨周期持久 | 编译期定长 | 所有 POU 变量的家(见 3.3) |
| I/O 映像区 | 跨周期持久 | 加载期固定(各 64KB) | %I 输入 / %Q 输出 / %M 内部存储 |
| 操作数栈 | 单周期内 | 加载期定容量 | 表达式求值的临时中转 |
| 字符串竞技场 | 混合 | 动态 | 常量区永久 + 临时区按周期回收(见第六节) |
I/O 映像是 PLC 的血统所在:LDV/STV 指令在译码阶段就区分普通变量区和三个映像区,包括字节内位寻址。物理输入输出的同步由宿主完成,ST 程序永远只读映像------这是 PLC 确定性的根基,VM 把它固化进了内存模型。
3.2 StackValue:一个为热路径妥协的带标签联合
操作数栈的每个槽位是自研的带标签联合:
cpp
enum class ValueType : uint8_t {
BOOL = 0, INT = 1, REAL = 2, TIME = 3, STR = 4,
};
struct StackValue {
ValueType type;
union {
bool b;
int64_t i;
double r;
int64_t t_us; // TIME 以微秒存储
// STR 复用 i 字段存 string_arena 索引,不额外占空间
};
};
设计上有三个刻意的取舍:
- 不用
std::variant。操作数栈是每秒千万次触碰的热路径,variant 的访问开销和构造语义在这里都是奢侈品;裸联合 + 一个 type 标签足够。 - 统一槽位尺寸 。所有变量槽按 8 字节对齐分配,编译器保证
computeSize ≥ 8------内存布局简单到不需要对齐逻辑。 - STR 只是句柄。栈上永远不存字符串内容,只存竞技场索引;字符串的生死由 Arena 统一管理,VM 核心对其无感。
每个取值都有 toBool()/toInt()/toReal() 隐式转换路径------类型检查在前端做完了,VM 里的转换是宽容的运行时兜底(比如 STR 转 BOOL 判"句柄有效"),宁可给出语义上无意义但不崩溃的结果。对控制程序来说,崩溃的代价远大于一次怪异赋值。
3.3 变量都在全局区:一个反教科书的决定
经典栈式 VM 的函数调用是教科书式的:参数压栈、栈帧压栈、返回值压栈。STVM 没有这么做------
**所有 POU(程序组织单元)的变量,包括参数和局部变量,都在编译期静态分配进全局变量区的非重叠偏移上。**每个 POU 有一个 global_base,局部变量地址 = global_base + 参数区大小 + 局部偏移。调用约定随之变得非常朴素:
CALLP调用用户 POU:返回地址压入call_stack(只存return_ip和saved_bp),跳转到 POU 入口;- 参数传递:调用方用
STV写入被调方的参数偏移,不经过操作数栈; - 返回值:函数 POU 的返回变量在编译期隐式创建(以函数名命名),调用方
LDV读取。
代价是无法递归(每次调用共享同一块变量区),换来的是三样对 PLC 更重要的东西:
- 零动态分配:变量区大小编译期确定,加载时一次分配,运行期没有任何堆操作;
- 确定性时序:每个 POU 的内存足迹是静态常量,便于做最坏情况分析;
- 调试极简 :宿主拿到 DebugSymbol(变量名 → 偏移 + 类型)就能直接
readVar/writeVar,在线监控不需要解析任何栈帧。
ST 程序的函数调用深度天然很浅、递归几乎不存在(工控规范甚至明确不建议),这个取舍在这里是正收益。栈帧指令 PUSHFrame/POPFrame 保留在 ISA 里,为将来需要重入语义的场景预留。
四、指令集:40 条指令的克制
4.1 定长 6 字节编码
每条指令固定 6 字节:1B opcode + 1B flags + 4B operand。
cpp
#pragma pack(push, 1)
struct Instruction {
uint8_t op;
uint8_t flags; // 预留:标记 operand 类型等
uint32_t operand;
};
static_assert(sizeof(Instruction) == 6, "Instruction must be 6 bytes");
#pragma pack(pop)
变长编码省内存,定长编码买确定性:取指永远是 ip += 6,译码器不需要解析长度表,字节码里任何一条指令的地址都可以 O(1) 定位(跳转目标、断点、行号表全都受益)。对代码尺寸普遍只有几十 KB 的 ST 程序,变长省下的那点空间不值得用复杂度去换。
4.2 编号分段即文档
40 条指令(28 核心 + 12 IEC 扩展)的 opcode 按功能分段,编码值本身就是分类表:
| 段 | 指令 | 说明 |
|---|---|---|
| 0x00 | NOP | 空指令 |
| 0x01--0x06 | LDC / LDV / STV / LDVP / POP / DUP | 数据搬运 |
| 0x10--0x16 | ADD / SUB / MUL / DIV / MOD / NEG / POW | 算术 |
| 0x20--0x28 | EQ / NE / GT / LT / GE / LE / AND / OR / XOR | 逻辑与比较 |
| 0x30--0x33 | JMP / JMPT / JMPF / RET | 流程控制 |
| 0x40--0x4C | CALLB / CALLP / RETV / LDA / LDF / STF / LDX / STX / CATS / CAST / PUSHFrame / POPFrame | IEC 扩展 |
| 0xF0 | SVC | 系统调用(operand = 实例索引) |
| 0xFE | HALT | VM 内部停机,不计入正式 ISA |
两个值得展开的设计点:
LDF/STF/LDX/STX------用 operand 承载"第二个操作数" 。结构字段偏移、数组元素大小这类量,不进常量池、不进栈,直接编进指令的 4B operand。一次访存一条指令,数组读写(先 LDA 压入元素地址,再 LDX/STX 按元素大小取存)依然是最紧凑的形态。
SVC------所有平台交互的唯一出口 。VM 不认识 GPIO、串口、EtherCAT,它只认识一条 SVC 指令。operand 是"系统调用实例索引",每个实例在 svc_states 里有独立的持久状态。这个设计把两件事分干净了:
- VM 与硬件解耦:宿主注册什么能力,VM 就有什么能力,移植到不同硬件只改 SyscallTable;
- 功能块状态显式化 :TON/TOF/CTU/CTD/SR/RS 这些 IEC 功能块的本质是带内部状态的定时器/计数器,状态由 VM 按实例管理、跨周期保留,而不是散落在用户全局变量里。
4.3 文件格式:26 字节头锁死契约
cpp
struct BytecodeHeader {
uint32_t magic; // 'STVM'
uint16_t version; // 0x0001
uint32_t global_size; // 全局变量区大小
uint32_t const_count; // 常量个数
uint32_t code_size; // 字节码大小
uint32_t entry_point; // 入口地址
uint32_t checksum; // CRC32(覆盖常量池 + 代码段)
};
static_assert(sizeof(BytecodeHeader) == 26, "BytecodeHeader is 26 bytes (packed)");
#pragma pack(1) 紧凑布局 + static_assert 编译期锁死尺寸------文件格式这种跨进程、跨机器的契约,任何隐式 padding 都是事故。序列化统一小端手写字节序读写,不做任何平台假设。Loader 端校验魔数、版本、CRC32 三关,一个损坏的字节码文件进不了执行器。
五、执行器:配额驱动的取指循环
5.1 主循环
cpp
ExecResult VM::execute(int quota) {
ctx_.instruction_quota = quota;
return vm_execute(ctx_); // while (quota > 0) { 取指→译码→执行→quota-- }
}
每次调用最多执行 quota 条指令,返回四种状态之一:
| 状态 | 含义 |
|---|---|
| FINISHED | ip 走到程序末尾 |
| YIELDED | 配额耗尽,执行状态完整保留在 ctx 里 |
| STOPPED | 宿主请求停止 |
| ERROR | 运行时错误,error_msg 已填好 |
这是整个 VM 实时可控性的来源:执行权永远以"配额"为单位归还给宿主 。一个 WHILE TRUE 循环最多烧掉 quota 条指令就会交还控制权------旧方案里需要 Watchdog 重启整机才能解决的问题,在这里只是一次普通的函数返回。
5.2 错误处理:失败要能定位到源码行
运行时防御覆盖了所有非法路径:栈溢出/下溢、除零与模零、常量索引越界、I/O 区非法、变量偏移越界、数组地址越界、无效字符串句柄、未知 opcode、非法 POU 索引。所有检查命中后走同一条路:填 error_msg、立即返回 ERROR,绝不带病执行。
一处小而关键的设计:错误消息会追加源码行号 。VM_Context 里有一张与指令一一对应的行号表(ip / 6 直接索引),错误发生时查表拼上 [line N]。用户在 IDE 里看到 "division by zero line 42",而不是一个裸的运行时错误------调试体验的成本只是加载时多带一张 uint32_t 表。
六、PLC 扫描语义:softReset 是这类 VM 的灵魂
通用脚本 VM(Lua/Python)跑完就结束,PLC 虚拟机的核心却是周期性的"无限重生" :每个扫描周期重新执行一遍程序,而变量状态要活着跨过周期边界。STVM 用一个 softReset() 把这对矛盾解干净:
| 状态 | softReset 后 | 理由 |
|---|---|---|
| ip / sp / call_stack | 归零/清空 | 程序从入口重新开始 |
| 操作数栈 | 清空 | 表达式中间态不属于周期 |
| global_mem | 保留 | 用户变量跨周期持久 |
| svc_states | 保留 | TON 计时、CTU 计数跨周期累积 |
| i/q/m 映像区 | 保留 | I/O 状态与物理世界连续 |
| string_arena 临时区 | O(1) 重置 | 临时字符串按周期回收(见下) |
字符串竞技场(StringArena)是其中最精巧的一块。字符串操作(拼接、内建函数结果)每次都产生新串,天然是"无界增长泄漏"的温床。Arena 用三段式布局解决:
[0, base) 常量区 ------ 编译期加载,永不动
[base, hi) 临时区 ------ 当前周期的活跃字符串
[hi, size) 预留区 ------ 上个周期用过、本周期可复用的槽位
reset() 只是把 hi 拨回 base,O(1)、零释放,槽位容量原地复用------内存峰值即历史峰值,之后不再增长。周期任务跑一周和跑一年,内存曲线是同一条水平线。
配套约束:调用 reset 前必须保证栈上没有指向临时区的 STR 句柄------扫描周期边界恰好满足这个条件(栈已清空),这也是为什么 reset 被设计成发生在周期边界而不是任意时刻。
七、调度器:从协程理想回到扫描现实
调度器经历过一次架构收敛。早期设计是 libco 协程 + 时间片轮转:任务在配额中途让出,调度器切换协程,追求通用多任务系统的灵活。最终落地的是标准 PLC 顺序扫描模型:
#mermaid-svg-o9potAyPQFE75xxJ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-o9potAyPQFE75xxJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-o9potAyPQFE75xxJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-o9potAyPQFE75xxJ .error-icon{fill:#552222;}#mermaid-svg-o9potAyPQFE75xxJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-o9potAyPQFE75xxJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-o9potAyPQFE75xxJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-o9potAyPQFE75xxJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-o9potAyPQFE75xxJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-o9potAyPQFE75xxJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-o9potAyPQFE75xxJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-o9potAyPQFE75xxJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-o9potAyPQFE75xxJ .marker.cross{stroke:#333333;}#mermaid-svg-o9potAyPQFE75xxJ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-o9potAyPQFE75xxJ p{margin:0;}#mermaid-svg-o9potAyPQFE75xxJ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-o9potAyPQFE75xxJ .cluster-label text{fill:#333;}#mermaid-svg-o9potAyPQFE75xxJ .cluster-label span{color:#333;}#mermaid-svg-o9potAyPQFE75xxJ .cluster-label span p{background-color:transparent;}#mermaid-svg-o9potAyPQFE75xxJ .label text,#mermaid-svg-o9potAyPQFE75xxJ span{fill:#333;color:#333;}#mermaid-svg-o9potAyPQFE75xxJ .node rect,#mermaid-svg-o9potAyPQFE75xxJ .node circle,#mermaid-svg-o9potAyPQFE75xxJ .node ellipse,#mermaid-svg-o9potAyPQFE75xxJ .node polygon,#mermaid-svg-o9potAyPQFE75xxJ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-o9potAyPQFE75xxJ .rough-node .label text,#mermaid-svg-o9potAyPQFE75xxJ .node .label text,#mermaid-svg-o9potAyPQFE75xxJ .image-shape .label,#mermaid-svg-o9potAyPQFE75xxJ .icon-shape .label{text-anchor:middle;}#mermaid-svg-o9potAyPQFE75xxJ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-o9potAyPQFE75xxJ .rough-node .label,#mermaid-svg-o9potAyPQFE75xxJ .node .label,#mermaid-svg-o9potAyPQFE75xxJ .image-shape .label,#mermaid-svg-o9potAyPQFE75xxJ .icon-shape .label{text-align:center;}#mermaid-svg-o9potAyPQFE75xxJ .node.clickable{cursor:pointer;}#mermaid-svg-o9potAyPQFE75xxJ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-o9potAyPQFE75xxJ .arrowheadPath{fill:#333333;}#mermaid-svg-o9potAyPQFE75xxJ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-o9potAyPQFE75xxJ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-o9potAyPQFE75xxJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-o9potAyPQFE75xxJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-o9potAyPQFE75xxJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-o9potAyPQFE75xxJ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-o9potAyPQFE75xxJ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-o9potAyPQFE75xxJ .cluster text{fill:#333;}#mermaid-svg-o9potAyPQFE75xxJ .cluster span{color:#333;}#mermaid-svg-o9potAyPQFE75xxJ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-o9potAyPQFE75xxJ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-o9potAyPQFE75xxJ rect.text{fill:none;stroke-width:0;}#mermaid-svg-o9potAyPQFE75xxJ .icon-shape,#mermaid-svg-o9potAyPQFE75xxJ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-o9potAyPQFE75xxJ .icon-shape p,#mermaid-svg-o9potAyPQFE75xxJ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-o9potAyPQFE75xxJ .icon-shape .label rect,#mermaid-svg-o9potAyPQFE75xxJ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-o9potAyPQFE75xxJ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-o9potAyPQFE75xxJ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-o9potAyPQFE75xxJ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 周期到期判定
一个扫描周期 runOnce()
Task 1
周期 1ms
quota 1000
Task 2
周期 10ms
quota 500
Task 3
一次性任务
宿主 updateClock(now_us)
- 每个任务一个完整的独立 VM_Context,状态零共享、零锁;
- 周期任务按
period_us判定到期触发,到期即执行完整 quota 或直到 FINISHED; - 周期任务 FINISHED 后自动软重置重启 (
auto_restart_periodic),进入下一个周期。
放弃协程换来了三样更值钱的东西:执行顺序完全确定 (PLC 程序可复现的前提)、任务间零同步开销 (没有并发就没有竞态)、抖动分析简单到可以直接测量。PLC 的世界是扫描周期驱动的,顺序扫描就是它的原生并发模型------通用多任务那套灵活性在这里是负资产。配额机制仍然保留,作为单任务失控的保险丝,但"任务中途让出"这个需求本身被消解了。
调度器还内置了 CPU 亲和性 :配置 cpu_affinity = N 后,首次 runOnce() 自动把调度线程绑定到指定核心,析构时自动解绑;绑定失败静默降级为不绑定。绑定一次、全程生效,避免每个扫描周期重复系统调用。对时序确定性来说,把 OS 调度器的线程迁移排除在外,是零成本高收益的一步。
八、轻量化的账本
| 组件 | 开销 | 说明 |
|---|---|---|
| VM 本体(代码段) | 数百 KB 量级 | 解释器 + ISA,无外部运行时依赖 |
| 全局变量区 | ~1KB / 任务 | 编译期确定 |
| I/O 映像 | 64KB × 3 / VM | 可按需裁剪 |
| 操作数栈 | 固定容量 / 任务 | 加载期分配,运行期零动态 |
| 协程栈 | 无 | 顺序扫描方案下已不存在 |
零依赖、零动态分配、定长一切------这三条贯穿了从 StackValue 到调度器的每一层。C++11 是实现的另一条纪律:不用异常做控制流(错误全部走返回值 + error_msg)、热路径无堆分配、头文件与实现分离,移植到任何能跑 C++11 编译器的平台都只是换一个工具链的事。
九、结语:这个 VM 教会我们的三件事
- 域决定架构 。同样是栈式 VM,把"Lua 的游戏脚本"换成"PLC 的扫描周期",正确的并发模型、内存生命周期、reset 语义就全变了。
softReset+ 顺序扫描不是什么高深技术,它们只是忠实地翻译了 PLC 的领域语义。 - 静态分配是嵌入式 VM 的第一美德。变量静态进全局区、参数走内存不走栈、字符串 Arena 化、配额定长编码------每一处"牺牲灵活性"都在为确定性时序和零动态分配还债,而在工控场景,确定性就是产品力。
- 安全性是执行循环的内建属性,不是外挂。配额防失控、边界检查防越界、CRC32 防损坏、行号表防黑盒------这些机制全部长在执行路径上,一个字节码文件从 Loader 到 ERROR 返回的每一刻都在被约束。
后续的演进方向(在线断点调试、性能剖析器、梯形图前端)都建立在这套地基上:断点只需要在配额检查点拦截 ip,剖析只需要累加 total_ins_count------执行权归宿主所有,是一切上层工具的前提。