# StScan:子集 ST + toml 工程 + 寄存器 VM 独立方案说明。调研全文见 `PLC-DSL.md`。 **定案:** IEC 风格的子集 ST,用 `.toml` 组织多个 `.st`,编译成单一映像,由寄存器虚拟机按扫描周期执行。 --- ## 1. 做什么、不做什么 ```text 多个 .st 源文件 ↑ 由 project.toml 列出 ↓ 编译器(词法/语法 → 符号表 → 类型检查 → 寄存器码 → 链接) ↓ 单一映像(函数字节码 + 全局/FB 数据槽 + 工程哈希) ↓ 寄存器虚拟机,按扫描周期执行 ``` **做** - 子集 ST(不是完整 IEC) - `.toml` 管工程,不声明变量 - 寄存器 VM + 扫描周期 - 有界 `CALL` / `RET` - 全局只在指定 GVL 文件里声明 **不做** - 「很像 ST 的 Python」 - 完整 IEC:指针、OOP、`ANY`、`VAR_IN_OUT`、间接寻址 - CPython 特化 / JIT / 真 Python 模块系统 - 第一版不做梯形图编辑器(以后编译到同一套 IR) 指令表可作为内部 IR 的文本形态,不必当主语言。 --- ## 2. 执行模型 ```text 每个扫描周期: 1. 按 toml 的 io 绑定,采样 → I / 输入全局 2. 从 PROGRAM MAIN 的 pc=0 执行到 RET 3. 写回 Q / 输出全局 4. 用本周期 Δt 推进 TON / TOF / CTU(不用 wall clock) ``` 运行时是 **一台 VM、一个入口、一份映像**。多文件只影响源码怎么切、怎么链接。 --- ## 3. 工程文件(toml) toml **不声明 ST 变量**。只做三件事:列出源文件、指定唯一 GVL、(可选)把已声明的全局接到硬件。 ```toml [project] name = "line1" entry = "program MAIN" cycle_limit = 100000 dt_ms = 10 [files] st = ["globals.st", "motor.st", "main.st"] # 全工程只允许这一处出现 VAR_GLOBAL(第一版:单个文件) [gvl] file = "globals.st" # 可选:I/O 绑定。不创造变量,var 必须已在 GVL 里声明 [[io.input]] var = "EmergencyStop" channel = 0 bit = 2 [[io.input]] var = "I0_0" channel = 0 bit = 0 [[io.output]] var = "Q0_0" channel = 0 bit = 0 ``` 规则: - 所有源文件编译前已知;无运行时加载。 - `gvl.file` 自动并入编译集合(已在 `files.st` 里则不重复)。 - 文件集合排序后算 **工程哈希**,写入映像,回放时槽位一致。 - 同名 `VAR_GLOBAL` 链接失败,禁止覆盖。 - `[[io.*]]` 的 `var` 未在 GVL 中出现 → 编译错误。 - 第一版禁止:模块级副作用、实例/函数体循环调用、函数指针、无界递归。 不要在 toml 里用 `[[globals]] name/type/init` 再声明变量。 名字和类型属于 ST;针脚和通道属于工程。 --- ## 4. ST 子集(第一版) **保留** | 类别 | 内容 | |---|---| | POU | `PROGRAM` / `FUNCTION` / `FUNCTION_BLOCK` | | 变量段 | `VAR` / `VAR_INPUT` / `VAR_OUTPUT` / `VAR_GLOBAL` / `VAR_EXTERNAL` | | 类型 | `BOOL`、`INT`、`TIME`(以后再加 `DINT` / 定点数或 `REAL`) | | 运算 | `:=`,`AND` `OR` `NOT`,比较,算术 | | 控制 | `IF / ELSIF / ELSE / END_IF`,`WHILE … END_WHILE` | | 功能块 | 内置 `TON` / `TOF` / `CTU`;`fb(in := …)`,字段 `fb.Q` | `WHILE` 不做终止证明,靠 `cycle_limit` 卡死。 `AND` / `OR` **必须短路**,编成跳转;两边都算会让定时器、FB 多执行。 **第一版不做:** 指针、`REF`、接口、类、`VAR_IN_OUT`、`ANY`、间接寻址、用户类型套娃、`FUNCTION` 改全局。 --- ## 5. 全局变量 ST 有全局:`VAR_GLOBAL` 声明,`VAR_EXTERNAL` 引用。`I` / `Q` / `M` 也是全局映像。 `FUNCTION` 第一版禁止写全局。 ### 5.1 在哪个文件声明 **第一版:只有 `globals.st` 可以出现 `VAR_GLOBAL`。** 多文件指 PROGRAM / FB / FUNCTION 分文件,不是全局可以散落声明。 | 种类 | 放哪 | 几个文件 | |---|---|---| | `VAR_GLOBAL` / `CONSTANT` / `RETAIN` | `globals.st` | **1 个** | | `I` / `Q` / `M` | 同一份 `globals.st` | 第一版不拆 | | FB / PROGRAM 的 `VAR` | 各自的 `.st` | 多个(局部 / 实例) | | `VAR_EXTERNAL` | 各 POU 的 `.st` | 多个(只引用) | 其他文件写 `VAR_GLOBAL` → 编译错误。 单文件的好处:声明顺序 = 地址顺序,不必再规定多 GVL 的排序和回放。 以后要拆,仍须 toml **显式列出全部 GVL**,按类别拆,不按功能块拆: ```toml [gvl] files = ["io.st", "globals.st"] ``` ```text io.st 只放 I/Q/M (以后才拆) globals.st 只放命名全局 motor.st 只有 FB + VAR_EXTERNAL main.st 只有 PROGRAM ``` 不要 `motor_globals.st`、`pump_globals.st` 各写一套同名点。 ### 5.2 寿命 | 种类 | 寿命 | |---|---| | `VAR` / `VAR_TEMP` | 本次调用或该 POU 内部 | | FB 的 `VAR` | 跟着实例(各一份) | | `VAR_GLOBAL` | 全工程一份 | | `I/Q/M` | 全局,绑定 I/O 映像 | `VAR_EXTERNAL` 接到已有槽,不是新变量。 第一版按 `globals.st` 声明顺序定址;以后若 `[gvl] files`:先 toml 列表,再文件内顺序。 ### 5.3 编译器数据流 ```text project.toml files.st → 要编译的源 gvl.file → 唯一允许 VAR_GLOBAL 的文件 io.* → 名字 → 硬件(可选,不创造变量) globals.st VAR_GLOBAL / CONSTANT / I/Q/M → 符号表 + 固定槽位 motor.st / main.st VAR_EXTERNAL → 同一槽 ``` --- ## 6. 多文件导出什么 | 导出 | 规则 | |---|---| | `FUNCTION` | 无内部状态;寄存器帧在有界调用栈上 | | `FUNCTION_BLOCK` | 必须先声明实例 | | `VAR_GLOBAL`、`I/Q/M` | 全工程一份,只在 GVL 里声明 | | 常量 | 进常量表 | `CALL` 目标都是立即数 `fn_id`,没有函数指针。 --- ## 7. 寄存器机与指令 编码宽度、操作码取值、映像头与定宽类型以 [`isa/指令与映像.md`](isa/指令与映像.md) 为准。本节只列助记符。 - 局部 / `VAR` = 固定寄存器;表达式临时值往后编号;`nregs` 写进函数头。 - `I/Q/M` 走映像指令,不是通用寄存器。 - 每个 FB 实例一块固定布局,字段偏移编译期算死。 ```text MOVE rd, rs LOADK rd, const_id NOT rd, rs AND/OR rd, ra, rb (* 已算好的值;短路用跳转 *) ADD/SUB/MUL/DIV rd, ra, rb CMP_xx rd, ra, rb (* 结果 BOOL *) JMP offset JT/JF r, offset LOAD_I / STORE_Q / LOAD_M / STORE_M LOAD_GLOBAL / STORE_GLOBAL CAL_TON / CAL_TOF / CAL_CTU CALL fn_id RET ``` 寄存器机不会比栈机更确定。确定性来自类型、内存、时间和副作用。 --- ## 8. 确定性与可控 - 同一程序 + 同一组 `I` + 同一 `Δt` → `Q`、`M`、FB 状态完全一致。 - 无 GC、无堆、无线程;变量编译期全部分配。 - 定宽整数;溢出规则写死(饱和或报错,选一个)。 - 第一版不要 `REAL`。 - 每周期指令上限(如 `cycle_limit = 100000`),超时是故障。 - 可单步、可看寄存器和映像、可按录制的 `I` 回放。 --- ## 9. 编译器 中等工程,不是算法难题。直通 pass,不上 LLVM: ```text 读 toml、收齐 .st → 词法 / 递归下降语法 → 收集导出符号 → 解析 VAR_EXTERNAL、链接、定地址 → 类型检查 → 按 PROGRAM / FUNCTION / FB 编寄存器码 → 回填跳转、写映像头与工程哈希 ``` | 部分 | 难度 | |---|---| | ST 解析 | 中低 | | 拒多余特性 | 低 | | 多文件链接 | 中 | | 类型检查 | 低~中 | | 寄存器码(短路、IF/WHILE) | 中 | | FB 布局与调用 | 中 | | VM | 低 | 第一版不做:图染色分配、SSA、证明 WHILE 有界、异常表、闭包、优化。 一人约 **4~8 周** 做出 `MAIN + 一个 Motor FB + I/O + 全局`。 --- ## 10. 目录 仓库布局见根目录 `README.md`。实现时按模块拆 CMake 目标(三个库 + 两个可执行),样例不放仓库根: ```text isa/ # 指令编码、映像头、定宽类型(compiler 与 vm 的合同) compiler/ # lexer parser symbols checker codegen linker;STCompiler 入口在 src/main.cpp vm/ # 扫描周期、寄存器、映像、FB(库) executor/ # BytecodeExecutor:加载 .stb + sidecar,喂 I、收 Q、Δt、回放 examples/line1/ # 第 11 节最小样例(给人看) tests/ # 冻规则用的正例 / 负例 ``` --- ## 11. 最小样例 `project.toml` 见第 3 节。 ```st (* globals.st *) VAR_GLOBAL EmergencyStop : BOOL := FALSE; I0_0 : BOOL; I0_1 : BOOL; Q0_0 : BOOL; END_VAR (* motor.st *) FUNCTION_BLOCK MotorStarter VAR_EXTERNAL EmergencyStop : BOOL; END_VAR VAR_INPUT start, stop : BOOL; END_VAR VAR_OUTPUT Q : BOOL; END_VAR Q := start AND NOT stop AND NOT EmergencyStop; END_FUNCTION_BLOCK (* main.st *) PROGRAM MAIN VAR starter : MotorStarter; END_VAR starter(start := I0_0, stop := I0_1); Q0_0 := starter.Q; END_PROGRAM ``` --- ## 12. 实现顺序 原则:**先冻合同,再解析,再链接,最后写 VM**。`compiler` 与 `vm` 不互链,合同只进 `isa`。每一阶段先有可运行的测试,再进入下一阶段。一人约 4~8 周做到第 11 节 `line1`。 第一版不做:图染色、SSA、WHILE 有界证明、异常表、闭包、优化、`REAL`。 --- ### 12.0 骨架(约 1 天) CMake 三个库能空编译:`isa`、`compiler`、`vm`;两个可执行 `STCompiler`、`BytecodeExecutor` 先打印版本即可。 - 落下 `examples/line1/`:第 3 节 toml + 第 11 节三个 `.st`。 - `tests/` 接上 CTest,先放一个必过的空测试。 - 溢出策略当场选定并写进 `isa` 注释:**饱和**或**报错**,只留一种。 完成:`cmake --build` 过;`ctest` 能跑。 --- ### 12.1 冻合同(约 2~3 天,先于任何解析器) 四张表写进代码或 `Doc/`,改表要同步改测试。未冻完不要写递归下降。 **toml 字段(只这些)** | 字段 | 含义 | 缺省 / 约束 | |---|---|---| | `project.name` | 工程名 | 必填 | | `project.entry` | 入口 | 第一版必须是 `program MAIN` | | `project.cycle_limit` | 每周期指令上限 | 如 `100000`;超时是故障 | | `project.dt_ms` | 本周期 Δt | 给 TON / TOF / CTU,不是 wall clock | | `files.st` | 源文件列表 | 编译前全部已知 | | `gvl.file` | 唯一允许 `VAR_GLOBAL` 的文件 | 已在 `files.st` 则不重复收录 | | `[[io.input]]` / `[[io.output]]` | `var` + `channel` + `bit` | 可选;`var` 必须已在 GVL | 禁止 toml 里用 `[[globals]]` 再声明变量。 **关键字(第一版只认这些)** `PROGRAM` `FUNCTION` `FUNCTION_BLOCK` `END_PROGRAM` `END_FUNCTION` `END_FUNCTION_BLOCK` `VAR` `VAR_INPUT` `VAR_OUTPUT` `VAR_GLOBAL` `VAR_EXTERNAL` `END_VAR` `BOOL` `INT` `TIME` `IF` `ELSIF` `ELSE` `END_IF` `WHILE` `END_WHILE` `AND` `OR` `NOT` `TRUE` `FALSE` `TON` `TOF` `CTU` 其余一律当标识符或直接拒绝(`VAR_IN_OUT`、`REF`、`CLASS` 等出明确错误)。 **符号表一行** `name` / `kind` / `type` / `address` / `source_file` `kind` 至少:`global` / `external` / `local` / `input` / `output` / `pou` / `fb_instance` / `const`。 **指令(进 `isa`,约 20 条)** 布局、操作码、映像、哈希、饱和以 [`isa/指令与映像.md`](isa/指令与映像.md) 为准。第 7 节只列助记符。 完成:`isa` 能编解码一条指令;实现头文件,不另写第三份规范。 --- ### 12.2 约 20 个样例(与 12.1 同时列出,随后逐个点亮) 每个用例一个子目录(`project.toml` + `.st`)。先写文件和期望(通过 / 报哪类错),再实现。`examples/line1` 给人看;负例和切片放 `tests/`。 | # | 用例 | 期望 | |---|---|---| | 1 | 空 `PROGRAM MAIN` + `RET` | 通过 | | 2 | `BOOL` 赋值、`TRUE`/`FALSE` | 通过 | | 3 | `AND`/`OR` 短路(右侧有副作用时不执行) | 通过 | | 4 | `IF` / `ELSIF` / `ELSE` | 通过 | | 5 | `WHILE` 正常结束 | 通过 | | 6 | `WHILE` 打满 `cycle_limit` | 运行时故障 | | 7 | `INT` 四则 + 比较 | 通过 | | 8 | `TIME` 字面量 | 通过 | | 9 | GVL + `VAR_EXTERNAL` | 通过,同一槽 | | 10 | 非 GVL 文件写 `VAR_GLOBAL` | 编译错误 | | 11 | 同名 `VAR_GLOBAL` | 链接失败 | | 12 | `io.var` 未在 GVL | 编译错误 | | 13 | `FUNCTION` 纯计算 + `CALL` | 通过 | | 14 | `FUNCTION` 写全局 | 编译错误 | | 15 | FB 实例 + `fb.Q` + 命名输入 | 通过 | | 16 | 未声明实例就调 FB | 编译错误 | | 17 | `TON` + 固定 Δt | 到时 `Q` 为真 | | 18 | `TOF` / `CTU` | 行为与 Δt 一致 | | 19 | 函数体循环调用 / 无界递归 | 编译错误 | | 20 | `line1`(第 11 节) | I 组合 → `Q0_0` 与急停语义一致 | 完成:目录和期望表齐;此时大部分还编不过,属正常。 --- ### 12.3 toml 读取(`compiler`,约 2 天) 还不碰 `.st` 正文。 - 解析第 3 节字段;未知键报错。 - 拼出编译文件集合:`files.st` ∪ `gvl.file`,去重、记绝对/相对路径。 - 校验:缺文件、`entry` 不是 `program MAIN`、`io` 缺 `var`/`channel`/`bit`。 - 算出工程哈希(文件集合排序后);先对源文件算,映像还没有也没关系。 完成:对 `examples/line1/project.toml` 打出文件列表和哈希;缺文件的负例过。 --- ### 12.4 词法(`compiler`,约 2 天) - `(* *)` 注释、标识符、关键字、整数、`TIME` 字面量、`:=`、比较符、括号。 - 关键字与标识符分词;大小写规则写死(建议与 IEC 一样不敏感,实现时统一折成一种内部形)。 - 每个 token 带 `source_file` + 行列,供以后报错。 完成:把第 11 节三个文件 lex 成稳定 token 流;非法字符报错。 --- ### 12.5 递归下降语法(`compiler`,约 4~6 天) 只出 AST,不分配寄存器、不写映像。 顺序: 1. POU 外壳:`PROGRAM` / `FUNCTION` / `FUNCTION_BLOCK` 与对应 `END_*` 2. 变量段:`VAR` / `VAR_INPUT` / `VAR_OUTPUT` / `VAR_GLOBAL` / `VAR_EXTERNAL` 3. 类型名只接受 `BOOL` `INT` `TIME`;内置 FB 名 `TON` `TOF` `CTU` 4. 语句:赋值、`IF`、`WHILE`、FB 调用 `fb(in := …)`、字段 `fb.Q` 5. 表达式:`NOT`、`AND`/`OR`(AST 里保留短路语义)、比较、加减乘除 拒绝时给明确错误,不要 silently 忽略:`VAR_IN_OUT`、指针/`REF`、`CLASS`、`ANY`、用户类型套娃。 完成:`line1` 三个文件能 parse;用例 10、14、19 若已能在语法/结构层拒绝则先红后绿。 --- ### 12.6 符号表与链接(`compiler`,约 3~4 天) ```text 收集导出 → 校验 GVL → 解析 VAR_EXTERNAL → 定地址 → 校验 io.* ``` - 先扫完全部 POU 再解析引用,禁止边解析边执行。 - 仅 `gvl.file` 可出现 `VAR_GLOBAL`;声明顺序 = 全局槽顺序。 - `VAR_EXTERNAL` 接已有槽,种类/类型必须一致,不是新变量。 - `FUNCTION` 标成无状态;`FUNCTION_BLOCK` 必须先有实例才能调用。 - `CALL` 目标解析成 `fn_id`(立即数),无函数指针。 - 同名全局、未声明 `io.var`、重复 POU 名 → 失败。 - FB 实例:按类型算出固定布局(输入/输出/内部/内置 TON 等字段偏移)。 完成:用例 9~12、16 按表变绿;符号表能打印 `name kind type address file`。 --- ### 12.7 类型检查(`compiler`,约 2 天) - `:=`、比较、算术的类型规则;`AND`/`OR`/`NOT` 只吃 `BOOL`。 - `FUNCTION` 禁止写全局(含经 `VAR_EXTERNAL`)。 - FB 调用:命名输入类型匹配;`fb.Q` 等字段存在且类型对。 - 第一版无隐式宽化;`INT` 与 `TIME` 不混用。 完成:类型错误有稳定报文;合法 `line1` AST 带类型。 --- ### 12.8 寄存器码(`compiler`,约 5~7 天) 局部和 `VAR` 固定寄存器;表达式临时值往后编号;`nregs` 写进函数头。跳转先留洞,再回填。`AND`/`OR` **必须编成跳转**,禁止两边都算。 建议按指令面切片,每片都能 `isa` 编解码、必要时用临时 VM 桩执行: 1. `MOVE` `LOADK` `RET`,空 `MAIN` 出映像(对应用例 1~2) 2. `LOAD_I` `STORE_Q` `LOAD_GLOBAL` `STORE_GLOBAL` `LOAD_M` `STORE_M` 3. `NOT`,短路 `AND`/`OR`(用例 3) 4. `CMP_*` `JMP` `JT`/`JF` → `IF`(用例 4) 5. `WHILE` + 映像里带上 `cycle_limit`(用例 5;超限等 VM) 6. `ADD`/`SUB`/`MUL`/`DIV`(用例 7~8) 7. `CALL fn_id` + 有界帧(用例 13) 8. FB 实例块 + 字段偏移;`CAL_TON` / `CAL_TOF` / `CAL_CTU`(用例 15、17、18) 完成:`line1` 能写出映像(函数字节码 + 全局/FB 槽 + 工程哈希);跳转回填无悬空。 --- ### 12.9 VM 循环(`vm`,约 3~4 天) 此时才写执行循环。只认 `isa` 映像。 ```text 采样 I → pc=0 执行到 RET → 写回 Q → 用本周期 Δt 推进 TON/TOF/CTU ``` - 译码 + 大 `switch`;寄存器文件按函数头 `nregs` 分配。 - 每周期计指令数,超过 `cycle_limit` 报故障(用例 6)。 - 调用栈有界;`CALL`/`RET` 恢复 `pc` 与帧。 - 无 GC、无堆、无线程。同一映像 + 同一组 `I` + 同一 `Δt` → `Q`/`M`/FB 一致。 - 内置 FB:`CAL_TON`/`CAL_TOF`/`CAL_CTU` 只用映像里的 Δt,不读系统时钟。 完成:对手工拼的小映像能跑赋值和跳转;再接编译器产出的映像。 --- ### 12.10 双可执行:STCompiler 与 BytecodeExecutor(约 2~3 天) 编译与执行拆成两个可执行,映像 `.stb` 是中间产物: ```text STCompiler -o .stb # 编译:读 toml + .st → 映像 + sidecar BytecodeExecutor .stb [--cycles N] # 执行:加载 .stb + sidecar,跑 N 周期 ``` - `STCompiler` 链 `compiler` + `isa`;`BytecodeExecutor` 链 `vm` + `isa`,**不链 `compiler`**。 - 产物两个文件:`.stb`(映像,魔数 `STSC`)+ `.runtime.toml`(sidecar:I/O 绑定 var → 槽号 → channel/bit)。`[[io.*]]` 不进映像,sidecar 由 STCompiler 生成,不创造变量。 - `dt_ms` / `cycle_limit` 从映像头取,sidecar 不重复配置。 - 最小可观测:BytecodeExecutor 打印本周期 I/Q,或把寄存器/映像 dump 出来。 - 回放:读预先录制的 I 序列(文本即可),不接真实硬件。 完成:`STCompiler examples/line1/project.toml -o line1.stb` 产出两个文件;`BytecodeExecutor line1.stb` 能跑若干周期。 --- ### 12.11 闭环与冻测试(约 1 周) - 点亮第 12.2 节 20 个用例;负例检查报错类别,不查英文原文。 - `line1` 固定输入表:急停、start/stop 组合 → 期望 `Q0_0`。 - 回放:同一 I 序列跑两遍,槽位与哈希一致。 - 单步:能停在指令边界,看到寄存器和 I/Q/M(可先做打印,不必做 UI)。 完成标准(第一版可交付):**`MAIN` + 一个 Motor FB + I/O + 全局**,扫描周期可重复。 --- ### 12.12 建议日历 | 周 | 做完 | |---|---| | 1 | 12.0~12.4:骨架、合同、样例清单、toml、词法 | | 2 | 12.5:AST,能 parse `line1`,拒绝多余特性 | | 3 | 12.6~12.7:链接、定址、类型、io 校验 | | 4 | 12.8 前半 + 12.9 前半:赋值/短路/IF 出映像,VM 能跑 | | 5 | 12.8 后半 + 12.10:CALL、FB、两个可执行跑起来 | | 6 | 定时器/计数器、io、Δt、`line1` 语义 | | 7~8 | 20 用例、回放、单步、补漏 | 周 4 结束时就应能跑一个「读 I、写 Q」的假 MAIN。不要等 FB 和 TON 齐了才第一次执行。 --- ### 12.13 依赖方向(全程遵守) ```text STCompiler → compiler → isa BytecodeExecutor → vm → isa ``` - 指令或映像布局有变:先改 `isa` 和编解码测试,再改两端。 - 新 ST 特性:先加 12.2 的用例行,再改关键字 / AST / 检查 / 码 / VM。 - 不要为了一个 pass 新建 CMake 库。