给 PLC 写一个字节码虚拟机:STVM 虚拟机架构设计

给 PLC 写一个字节码虚拟机:STVM 虚拟机架构设计

项目地址: https://gitee.com/galaxy_0/stvm.git

一、为什么 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 索引,不额外占空间
    };
};

设计上有三个刻意的取舍:

  1. 不用 std::variant。操作数栈是每秒千万次触碰的热路径,variant 的访问开销和构造语义在这里都是奢侈品;裸联合 + 一个 type 标签足够。
  2. 统一槽位尺寸 。所有变量槽按 8 字节对齐分配,编译器保证 computeSize ≥ 8------内存布局简单到不需要对齐逻辑。
  3. STR 只是句柄。栈上永远不存字符串内容,只存竞技场索引;字符串的生死由 Arena 统一管理,VM 核心对其无感。

每个取值都有 toBool()/toInt()/toReal() 隐式转换路径------类型检查在前端做完了,VM 里的转换是宽容的运行时兜底(比如 STR 转 BOOL 判"句柄有效"),宁可给出语义上无意义但不崩溃的结果。对控制程序来说,崩溃的代价远大于一次怪异赋值。

3.3 变量都在全局区:一个反教科书的决定

经典栈式 VM 的函数调用是教科书式的:参数压栈、栈帧压栈、返回值压栈。STVM 没有这么做------

**所有 POU(程序组织单元)的变量,包括参数和局部变量,都在编译期静态分配进全局变量区的非重叠偏移上。**每个 POU 有一个 global_base,局部变量地址 = global_base + 参数区大小 + 局部偏移。调用约定随之变得非常朴素:

  • CALLP 调用用户 POU:返回地址压入 call_stack(只存 return_ipsaved_bp),跳转到 POU 入口;
  • 参数传递:调用方用 STV 写入被调方的参数偏移,不经过操作数栈;
  • 返回值:函数 POU 的返回变量在编译期隐式创建(以函数名命名),调用方 LDV 读取。

代价是无法递归(每次调用共享同一块变量区),换来的是三样对 PLC 更重要的东西:

  1. 零动态分配:变量区大小编译期确定,加载时一次分配,运行期没有任何堆操作;
  2. 确定性时序:每个 POU 的内存足迹是静态常量,便于做最坏情况分析;
  3. 调试极简 :宿主拿到 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 教会我们的三件事

  1. 域决定架构 。同样是栈式 VM,把"Lua 的游戏脚本"换成"PLC 的扫描周期",正确的并发模型、内存生命周期、reset 语义就全变了。softReset + 顺序扫描不是什么高深技术,它们只是忠实地翻译了 PLC 的领域语义。
  2. 静态分配是嵌入式 VM 的第一美德。变量静态进全局区、参数走内存不走栈、字符串 Arena 化、配额定长编码------每一处"牺牲灵活性"都在为确定性时序和零动态分配还债,而在工控场景,确定性就是产品力。
  3. 安全性是执行循环的内建属性,不是外挂。配额防失控、边界检查防越界、CRC32 防损坏、行号表防黑盒------这些机制全部长在执行路径上,一个字节码文件从 Loader 到 ERROR 返回的每一刻都在被约束。

后续的演进方向(在线断点调试、性能剖析器、梯形图前端)都建立在这套地基上:断点只需要在配额检查点拦截 ip,剖析只需要累加 total_ins_count------执行权归宿主所有,是一切上层工具的前提。

相关推荐
机器视觉知识推荐、就业指导22 分钟前
为什么老说“纯 Qt 没啥就业市场”?
开发语言·qt
telepan24 分钟前
Qt 开发避坑与性能指南:如何优雅、安全地定义全局常量字符串?
开发语言·qt
小灰灰搞电子30 分钟前
完全驾驭 Qt 与数据库:C++ ORM 框架 QxOrm 原理与实践指南
数据库·c++·qt
QT界面美化性能优化37 分钟前
QT+AI:使用AI技术为QT应用程序赋能
c++·人工智能·qt·opencv·qt教程·qt6.3
m0_6952514940 分钟前
Qt MaintenanceTool 使用国内镜像加速下载(超详细教程)
开发语言·qt
大衛說2 小时前
《Python 从入门到精通》系列总览与学习路线
开发语言·python·学习
学习星球3 小时前
单调栈——从“找下一个更大的“到柱状图中的最大矩形
数据库·c++·算法·leetcode·xcode
可乐鸡翅yeah_3 小时前
hls.js 内存泄漏实战排查,直播 M3U8 长时间播放异常定位方案
开发语言·javascript·python·django·ecmascript·m3u8·m3u8在线
2601_962203513 小时前
Java进阶07集合(续)
java·开发语言