- README.md:依赖方向(STCompiler→compiler 不链 isa)、模块表、isa 归执行器侧、 数据流图(machine.toml + 型号/SHA) - isa/README.md:重写——只管指令定义(Types/Op/Instr/Encode),删 Image 部分 - 指令与映像.md:依赖图与 CMake(compiler 不链 isa) - 编译管线.md:管线首步加载 machine.toml、产物含型号/SHA - 初步计划.md:12.10/12.13 修订(--machine、依赖方向) - 扫描周期.md / 指令执行.md:12.9 步骤标注已落地、vm 读校验说明 - compiler/vm/executor README:依赖与边界同步
20 KiB
StScan:子集 ST + toml 工程 + 寄存器 VM
独立方案说明。调研全文见 PLC-DSL.md。
定案: IEC 风格的子集 ST,用 .toml 组织多个 .st,编译成单一映像,由寄存器虚拟机按扫描周期执行。
1. 做什么、不做什么
多个 .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. 执行模型
每个扫描周期:
1. 按 toml 的 io 绑定,采样 → I / 输入全局
2. 从 PROGRAM MAIN 的 pc=0 执行到 RET
3. 写回 Q / 输出全局
4. 用本周期 Δt 推进内置 FB(TON/TOF/TP/CTU/CTD/CTUD/R_TRIG/F_TRIG,不用 wall clock)
运行时是 一台 VM、一个入口、一份映像。多文件只影响源码怎么切、怎么链接。
3. 工程文件(toml)
toml 不声明 ST 变量。只做三件事:列出源文件、指定唯一 GVL、(可选)把已声明的全局接到硬件。
[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,按类别拆,不按功能块拆:
[gvl]
files = ["io.st", "globals.st"]
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 编译器数据流
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 为准。本节只列助记符。
- 局部 /
VAR= 固定寄存器;表达式临时值往后编号;nregs写进函数头。 I/Q/M走映像指令,不是通用寄存器。- 每个 FB 实例一块固定布局,字段偏移编译期算死。
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_TP / CAL_CTU / CAL_CTD / CAL_CTUD / CAL_R_TRIG / CAL_F_TRIG
CALL fn_id
RET
寄存器机不会比栈机更确定。确定性来自类型、内存、时间和副作用。
8. 确定性与可控
- 同一程序 + 同一组
I+ 同一Δt→Q、M、FB 状态完全一致。 - 无 GC、无堆、无线程;变量编译期全部分配。
- 定宽整数;溢出规则写死(饱和或报错,选一个)。
- 第一版不要
REAL。 - 每周期指令上限(如
cycle_limit = 100000),超时是故障。 - 可单步、可看寄存器和映像、可按录制的
I回放。
9. 编译器
中等工程,不是算法难题。直通 pass,不上 LLVM:
读 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 目标(三个库 + 两个可执行),样例不放仓库根:
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 节。
(* 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 | 给内置 FB 定时器,不是 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
THEN DO (12.5 修订补入:IF…THEN / WHILE…DO 语法需要)
AND OR NOT
TRUE FALSE
TON TOF TP CTU CTD CTUD R_TRIG F_TRIG (12.11 修订:内置 FB 扩为 8 个)
其余一律当标识符或直接拒绝(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 为准。第 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,不分配寄存器、不写映像。
顺序:
- POU 外壳:
PROGRAM/FUNCTION/FUNCTION_BLOCK与对应END_* - 变量段:
VAR/VAR_INPUT/VAR_OUTPUT/VAR_GLOBAL/VAR_EXTERNAL - 类型名只接受
BOOLINTTIME;内置 FB 名TONTOFTPCTUCTDCTUDR_TRIGF_TRIG(12.11 修订:内置 FB 扩为 8 个) - 语句:赋值、
IF、WHILE、FB 调用fb(in := …)、字段fb.Q - 表达式:
NOT、AND/OR(AST 里保留短路语义)、比较、加减乘除
拒绝时给明确错误,不要 silently 忽略:VAR_IN_OUT、指针/REF、CLASS、ANY、用户类型套娃。
完成:line1 三个文件能 parse;用例 10、14、19 若已能在语法/结构层拒绝则先红后绿。
12.6 符号表与链接(compiler,约 3~4 天)
收集导出 → 校验 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 桩执行:
MOVELOADKRET,空MAIN出映像(对应用例 1~2)LOAD_ISTORE_QLOAD_GLOBALSTORE_GLOBALLOAD_MSTORE_MNOT,短路AND/OR(用例 3)CMP_*JMPJT/JF→IF(用例 4)WHILE+ 映像里带上cycle_limit(用例 5;超限等 VM)ADD/SUB/MUL/DIV(用例 7~8)CALL fn_id+ 有界帧(用例 13)- FB 实例块 + 字段偏移;
CAL_TON/CAL_TOF/CAL_CTU(用例 15、17、18)
完成:line1 能写出映像(函数字节码 + 全局/FB 槽 + 工程哈希);跳转回填无悬空。
12.9 VM 循环(vm,约 3~4 天)
此时才写执行循环。只认 isa 映像。
采样 I → pc=0 执行到 RET → 写回 Q → 用本周期 Δt 推进内置 FB
- 译码 + 大
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 是中间产物:
STCompiler <project.toml> -o <name>.stb --machine <machine.toml>
BytecodeExecutor <name>.stb [--cycles N]
STCompiler链compiler(不链isa,编码查machine.toml);BytecodeExecutor链vm+isa,不链compiler。- 产物两个文件:
<name>.stb(头 104 含型号标识 + 段 + SHA-256 文件尾)+<name>.runtime.toml(sidecar:I/O 绑定 var → 槽号 → channel/bit)。[[io.*]]不进映像,sidecar 由 STCompiler 生成,不创造变量。 dt_ms/cycle_limit从映像头取,sidecar 不重复配置。- 执行器读取时型号匹配 + SHA-256 校验,不匹配/篡改 → 拒绝。
- 最小可观测:BytecodeExecutor 打印本周期 I/Q,或把寄存器/映像 dump 出来。
- 回放:读预先录制的 I 序列(文本即可),不接真实硬件。
完成:STCompiler examples/line1/project.toml -o line1.stb --machine compiler/machine.toml 产出两个文件;BytecodeExecutor line1.stb 能跑若干周期。
12.13 修订:指令 opcode / 类型 / FB 布局的登记移入
compiler/machine.toml(见指令配置.md),STCompiler编译路径必填--machine。
12.11 闭环与冻测试(约 1 周)
- 点亮第 12.2 节 20 个用例;负例检查报错类别,不查英文原文。
line1固定输入表:急停、start/stop 组合 → 期望Q0_0。- 回放:同一 I 序列跑两遍,槽位与哈希一致。
- 单步:能停在指令边界,看到寄存器和 I/Q/M(可先做打印,不必做 UI)。
- TUI 界面最后做,v1 只预留接口:执行器(
BytecodeExecutor)的观察界面,数据走 vm 观察 API(见Doc/vm/扫描周期.md),展示与输出层分离(见Doc/executor/执行器入口.md)。
完成标准(第一版可交付):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 依赖方向(全程遵守)
STCompiler → compiler (编码查 machine.toml;自带 .stb 写)
BytecodeExecutor → vm → isa (isa 只管指令;vm 自带 .stb 读)
- 指令或映像布局有变:先改
isa和编解码测试,再改两端。 - 新 ST 特性:先加 12.2 的用例行,再改关键字 / AST / 检查 / 码 / VM。
- 不要为了一个 pass 新建 CMake 库。