给 PLC 写一个字节码虚拟机:STVM 虚拟机架构设计 给 PLC 写一个字节码虚拟机STVM 虚拟机架构设计项目地址 https://gitee.com/galaxy_0/stvm.git一、为什么 2026 年还要自研一个虚拟机STVM 要解决的问题很具体让客户的 STIEC 61131-3 结构化文本程序跑在资源有限的嵌入式网关上且不把维护成本留给自己。替代方案曾经是MatIEC 编译成 C 代码 GCC 交叉编译成 .so 设备加载这条路的痛点几乎每个做过的团队都踩过问题描述二进制兼容性客户换一次 Linux 内核版本所有 .so 重新编译部署复杂依赖 Docker 镜像嵌入式设备内存根本不够实时性不可控.so 里的死循环无法被打断只能靠 Watchdog 整体重启客户锁定弱.so 可被反编译客户容易整体迁移到竞品维护成本高GCC 版本、Glibc 符号问题制造源源不断的工单字节码虚拟机把这些问题的答案收敛成一句话把执行从编译工具链里剥离出来。ST 源码在 PC 端编译成自研.bytecode格式设备端只需要一个几百 KB 的解释器——字节码不依赖操作系统版本一次编译到处运行VM 核心内存占用控制在 MB 级适配 256MB RAM 的网关解释循环里埋了指令配额任何死循环都在 VM 的掌控之内字节码格式私有且带校验比裸 .so 多一层锁定实现全部 C11 自研零 GPL 传染。本文聚焦虚拟机本体的架构内存模型、指令集、执行循环、PLC 扫描语义和调度器。编译器前端AST、类型检查、代码生成是另一个话题这里只交代与 VM 的接口边界。二、总体架构一条编译期与运行期的分界线嵌入式设备PC / 开发环境 ST / IEC 源码 编译器前端词法 语法 语义检查字节码生成器.bytecode魔数 CRC32 校验Loader校验 映射VM 执行器取指·译码·执行·配额调度器PLC 顺序扫描系统调用层SVC 表 FB 状态宿主环境GPIO / 串口 / 现场 IO分界线两边的契约只有一个.bytecode文件格式 指令集。前端的词法、语法、类型检查怎么实现哪怕推倒重来只要产出的字节码不变设备端一行代码都不用动。这也是字节码方案最容易被低估的好处——前后端可以独立演进。三、虚拟机模型栈式解释器与五块内存3.1 运行时内存全景VM_Context 是整个执行器的世界拆开看是五块性质完全不同的内存内存区生命周期分配时机语义代码区随程序加载加载期只读指令流ip 在此游走全局变量区跨周期持久编译期定长所有 POU 变量的家见 3.3I/O 映像区跨周期持久加载期固定各 64KB%I输入 /%Q输出 /%M内部存储操作数栈单周期内加载期定容量表达式求值的临时中转字符串竞技场混合动态常量区永久 临时区按周期回收见第六节I/O 映像是 PLC 的血统所在LDV/STV指令在译码阶段就区分普通变量区和三个映像区包括字节内位寻址。物理输入输出的同步由宿主完成ST 程序永远只读映像——这是 PLC 确定性的根基VM 把它固化进了内存模型。3.2 StackValue一个为热路径妥协的带标签联合操作数栈的每个槽位是自研的带标签联合enumclassValueType:uint8_t{BOOL0,INT1,REAL2,TIME3,STR4,};structStackValue{ValueType type;union{boolb;int64_ti;doubler;int64_tt_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。#pragmapack(push,1)structInstruction{uint8_top;uint8_tflags;// 预留标记 operand 类型等uint32_toperand;};static_assert(sizeof(Instruction)6,Instruction must be 6 bytes);#pragmapack(pop)变长编码省内存定长编码买确定性取指永远是ip 6译码器不需要解析长度表字节码里任何一条指令的地址都可以 O(1) 定位跳转目标、断点、行号表全都受益。对代码尺寸普遍只有几十 KB 的 ST 程序变长省下的那点空间不值得用复杂度去换。4.2 编号分段即文档40 条指令28 核心 12 IEC 扩展的 opcode 按功能分段编码值本身就是分类表段指令说明0x00NOP空指令0x01–0x06LDC / LDV / STV / LDVP / POP / DUP数据搬运0x10–0x16ADD / SUB / MUL / DIV / MOD / NEG / POW算术0x20–0x28EQ / NE / GT / LT / GE / LE / AND / OR / XOR逻辑与比较0x30–0x33JMP / JMPT / JMPF / RET流程控制0x40–0x4CCALLB / CALLP / RETV / LDA / LDF / STF / LDX / STX / CATS / CAST / PUSHFrame / POPFrameIEC 扩展0xF0SVC系统调用operand 实例索引0xFEHALTVM 内部停机不计入正式 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 字节头锁死契约structBytecodeHeader{uint32_tmagic;// STVMuint16_tversion;// 0x0001uint32_tglobal_size;// 全局变量区大小uint32_tconst_count;// 常量个数uint32_tcode_size;// 字节码大小uint32_tentry_point;// 入口地址uint32_tchecksum;// CRC32覆盖常量池 代码段};static_assert(sizeof(BytecodeHeader)26,BytecodeHeader is 26 bytes (packed));#pragma pack(1)紧凑布局 static_assert编译期锁死尺寸——文件格式这种跨进程、跨机器的契约任何隐式 padding 都是事故。序列化统一小端手写字节序读写不做任何平台假设。Loader 端校验魔数、版本、CRC32 三关一个损坏的字节码文件进不了执行器。五、执行器配额驱动的取指循环5.1 主循环ExecResultVM::execute(intquota){ctx_.instruction_quotaquota;returnvm_execute(ctx_);// while (quota 0) { 取指→译码→执行→quota-- }}每次调用最多执行 quota 条指令返回四种状态之一状态含义FINISHEDip 走到程序末尾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 的灵魂通用脚本 VMLua/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拨回baseO(1)、零释放槽位容量原地复用——内存峰值即历史峰值之后不再增长。周期任务跑一周和跑一年内存曲线是同一条水平线。配套约束调用 reset 前必须保证栈上没有指向临时区的 STR 句柄——扫描周期边界恰好满足这个条件栈已清空这也是为什么 reset 被设计成发生在周期边界而不是任意时刻。七、调度器从协程理想回到扫描现实调度器经历过一次架构收敛。早期设计是 libco 协程 时间片轮转任务在配额中途让出调度器切换协程追求通用多任务系统的灵活。最终落地的是标准 PLC 顺序扫描模型周期到期判定一个扫描周期 runOnce()Task 1周期 1msquota 1000Task 2周期 10msquota 500Task 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 到调度器的每一层。C11 是实现的另一条纪律不用异常做控制流错误全部走返回值 error_msg、热路径无堆分配、头文件与实现分离移植到任何能跑 C11 编译器的平台都只是换一个工具链的事。九、结语这个 VM 教会我们的三件事域决定架构。同样是栈式 VM把Lua 的游戏脚本换成PLC 的扫描周期正确的并发模型、内存生命周期、reset 语义就全变了。softReset 顺序扫描不是什么高深技术它们只是忠实地翻译了 PLC 的领域语义。静态分配是嵌入式 VM 的第一美德。变量静态进全局区、参数走内存不走栈、字符串 Arena 化、配额定长编码——每一处牺牲灵活性都在为确定性时序和零动态分配还债而在工控场景确定性就是产品力。安全性是执行循环的内建属性不是外挂。配额防失控、边界检查防越界、CRC32 防损坏、行号表防黑盒——这些机制全部长在执行路径上一个字节码文件从 Loader 到 ERROR 返回的每一刻都在被约束。后续的演进方向在线断点调试、性能剖析器、梯形图前端都建立在这套地基上断点只需要在配额检查点拦截 ip剖析只需要累加total_ins_count——执行权归宿主所有是一切上层工具的前提。