11 · 脚本引擎:从代码到运行
程序集:Hmi.Scripting · 源码:src/Hmi.Scripting/
什么时候你会需要它
- 你写的脚本报错了,想知道到底哪一步出的问题
- 你的脚本被拦下来了(提示 Blocked),想搞明白为什么
- 你要在宿主程序里跑一段用户脚本(而不是在组态器里)
- 你想知道"用户脚本为什么不能删文件、不能开线程"
如果你只是想写脚本,直接看第 12 章和第 13 章。 这一章讲的是引擎内部怎么运作。
一句话概括
Hmi.Scripting 把用户写的一小段 C# 代码,安全地编译并执行,然后返回一个结果对象 —— 绝不抛异常给你。
四步流水线
用户代码(字符串)
│
▼
① 安全检查 ScriptSafetyAnalyzer.Inspect
├─ 用了 System.IO / System.Net / System.Reflection?
├─ 出现 File / Process / Assembly / Activator 这类标识符?
├─ 有 unsafe / 指针 / stackalloc?
└─ 命中任何一条 → 直接 Blocked,不往下走
│
▼
② 编译 CSharpScript.Create<object>
├─ 自动 import:System / System.Linq / Hmi.Scripting
├─ 引用函数库程序集
└─ 收集编译诊断(带行列号)
│
▼
③ 缓存(按 脚本名 + 代码哈希)
│
▼
④ 执行,带超时(默认 5 秒)
│
▼
ScriptRunResult { Ok / Outcome / Message / Diagnostics }
五种结果,别只看"成功失败"
public enum ScriptRunOutcome
{
Ok, // 跑成功了
CompileError, // 编译不过(语法错、类型错)
Blocked, // 被安全检查拦下
RuntimeError, // 编译过了,跑的时候抛异常了
Timeout, // 超时了
}
为什么要分这么细:这五种情况的排查方向完全不同:
| 结果 | 你要做什么 |
|---|---|
Ok |
没事 |
CompileError |
看 Diagnostics 里的行列号,改代码 |
Blocked |
你用了被禁止的 API —— 换成函数库里的方法(见下) |
RuntimeError |
逻辑问题:点不存在?类型不对?除零? |
Timeout |
死循环了,或者某步卡住了 |
⚠ 脚本执行失败不会抛异常,而是返回结果对象。 这是刻意的 —— 用户的脚本出错不能把宿主程序搞崩。所以调
RunAsync之后一定要检查result.Ok。
安全检查拦什么
完整黑名单(真源:ScriptSafetyAnalyzer.cs 的 BannedNamespaces / BannedIdentifiers):
① 禁用命名空间(出现在 using 或全限定名里就拦)
| 命名空间 | 为什么 |
|---|---|
System.IO |
能随便读写文件,等于拿到整台机器 |
System.Net |
能对外发网络请求 |
System.Reflection |
能绕过下面所有限制 |
System.Diagnostics |
能启进程 |
System.Runtime.InteropServices |
能 P/Invoke 调任意原生 API |
System.Threading |
能起后台线程把实时性搞乱 |
Microsoft.Win32 |
能读写注册表 |
System.Runtime.Loader |
能动态加载程序集 |
System.Security |
能改安全上下文 |
② 禁用标识符(裸用也拦 —— 防止用 using static 之类绕过命名空间检查)
File Directory FileStream Process Assembly Activator
DllImport Marshal GC Environment AppDomain AppContext
Type RuntimeHelpers Socket HttpClient WebClient Registry
③ 禁用语法
unsafe、指针类型、stackalloc
⚠ 是"黑名单"不是"白名单" —— 所以它拦的是常见危险,不是全部。 别指望它当沙箱用:彻底隔离要靠受管子进程(见第 17 章)。 它设计成"够拦常见危险,又不误伤正常逻辑脚本"。
所以脚本里要用文件操作怎么办? 用函数库里的 Fs 库(见第 13 章)——
它被限制在一个安全的根目录下,读不到外面的东西。
💡 这是"受控出口"思路:不让你碰底层 API,只给你一组被审计过的函数。 那些函数内部做完了安全检查,你调它们是安全的。
超时是怎么实现的
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(5);
超时用的是"协作式取消 + 竞速",不是强杀线程:
启动脚本任务 ──┐
├─ 谁先完成用谁
计时 5 秒 ───┘
⚠ 这意味着超时的脚本实际上还在后台跑,只是引擎不等它了。 所以别写死循环 —— 每超时一次就多一个僵尸任务。 写循环一定要有明确的上界。
ScriptGlobals:脚本能用的东西都从这里来
用户脚本里能写 Tag.Write(...)、Log.Info(...),这些不是全局函数,
而是引擎在编译时注入的一个上下文对象 ScriptGlobals 的属性:
public sealed class ScriptGlobals
{
public ScriptGlobals(
ITagGateway tagGateway, // 必需 —— 读写点的通道
IScriptLogSink? log = null, // 以下都可空,为空时注入"未就绪"占位件
IAlarmService? alarm = null,
IHistoryService? history = null,
IDbService? db = null,
IUiService? ui = null,
ICommService? comm = null,
IReportService? report = null,
IRecipeService? recipe = null,
Func<string>? currentUser = null,
string? fileRoot = null);
// 脚本里直接用的 16 个库
public TagApi Tag { get; }
public LogApi Log { get; }
public SysApi Sys { get; }
public TimerApi Timer { get; }
public Math2Api Math2 { get; }
public StringApi Str { get; }
public BitApi Bit { get; }
public FileApi Fs { get; }
public AlarmApi Alarm { get; }
public HistoryApi History { get; }
public DbApi Db { get; }
public UiApi Ui { get; }
public CommApi Comm { get; }
public ReportApi Report { get; }
public RecipeApi Recipe { get; }
}
只有第一个参数是必需的 —— 其余不传的话,对应的库会被替换成"未就绪"占位件,
脚本调它会抛 ScriptApiUnavailableException(而不是 NullReferenceException)。
这样错误信息能明确告诉你"这个能力没接上"。
每个库有哪些函数,见第 13 章。
直接用引擎:三步
using Hmi.Scripting.Engine;
using Hmi.Scripting;
// 1. 建引擎
var engine = new ScriptEngine();
// 2. 准备上下文(这里用手工装配,实际项目一般用 ScriptRuntimeBuilder,见第 12 章)
ScriptGlobals globals = new ScriptRuntimeBuilder(rtdb, project.Tags, writer)
.WithLog(logSink)
.Build();
// 3. 跑
ScriptRunResult r = await engine.RunAsync(
name: "demo",
code: "#PLC1.运行标志.实时值 = 真; Log.Info(\"跑完了\");",
globals: globals);
if (!r.Ok)
Console.Error.WriteLine($"[{r.Outcome}] {r.Message}");
只检查语法不执行:
ScriptCompileResult c = engine.Compile("demo", code);
if (!c.Success)
foreach (var d in c.Diagnostics)
Console.Error.WriteLine($"{d.Line}:{d.Column} {d.Severity} {d.Message}");
清缓存(改了函数库实现之后要调):
engine.ClearCache();
公开类型
| 类型 | 路径 | 干什么 |
|---|---|---|
ScriptEngine |
Engine/ScriptEngine.cs |
编译 + 缓存 + 超时执行 |
ScriptGlobals |
ScriptGlobals.cs |
注入脚本的上下文(16 个库) |
CompiledScript |
Engine/CompiledScript.cs |
编译产物 |
ScriptCompileResult / ScriptRunResult |
Engine/ |
编译/运行结果 |
ScriptRunOutcome |
Engine/ |
五种结果枚举 |
ScriptDiagnostic / ScriptDiagnosticSeverity |
Engine/ |
编译诊断(带行列号) |
ScriptSafetyAnalyzer |
Engine/ |
安全检查器 |
ScriptApiUnavailableException |
ScriptApiUnavailableException.cs |
能力没接上时抛的异常 |
踩过的坑
- 不看
result.Ok—— 脚本失败是静默的(不抛异常),不检查就等于没跑。 - 写死循环 —— 超时只是"不等了",任务还在后台跑,越积越多。
- 以为能删文件 ——
System.IO被拦。用Fs库。 ScriptGlobals只传了第一个参数 —— 其它库会变成"未就绪"占位件, 脚本调用时报ScriptApiUnavailableException。报这个异常 = 宿主没接上那个能力,不是脚本写错了。- 改了函数库没清缓存 —— 缓存按"脚本名 + 代码哈希"算,函数库实现变了但脚本代码没变时,会拿到旧行为。
小结
- 流水线:安全检查 → 编译 → 缓存 → 带超时执行
- 结果分五种(
Ok/CompileError/Blocked/RuntimeError/Timeout),排查方向完全不同 - 失败不抛异常,一定检查
result.Ok - 安全检查拦掉
System.IO/Net/Reflection/Threading/unsafe—— 要这些能力就用函数库 - 超时是协作式的,别写死循环
- 脚本能用的 16 个库都挂在
ScriptGlobals上
下一步:第 12 章 脚本运行时与脚本形态 —— 脚本怎么挂到工程上、什么时候被触发。