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 能力没接上时抛的异常

踩过的坑

  1. 不看 result.Ok —— 脚本失败是静默的(不抛异常),不检查就等于没跑。
  2. 写死循环 —— 超时只是"不等了",任务还在后台跑,越积越多。
  3. 以为能删文件 —— System.IO 被拦。用 Fs 库。
  4. ScriptGlobals 只传了第一个参数 —— 其它库会变成"未就绪"占位件, 脚本调用时报 ScriptApiUnavailableException。报这个异常 = 宿主没接上那个能力,不是脚本写错了。
  5. 改了函数库没清缓存 —— 缓存按"脚本名 + 代码哈希"算,函数库实现变了但脚本代码没变时,会拿到旧行为。

小结

  • 流水线:安全检查 → 编译 → 缓存 → 带超时执行
  • 结果分五种(Ok/CompileError/Blocked/RuntimeError/Timeout),排查方向完全不同
  • 失败不抛异常,一定检查 result.Ok
  • 安全检查拦掉 System.IO / Net / Reflection / Threading / unsafe —— 要这些能力就用函数库
  • 超时是协作式的,别写死循环
  • 脚本能用的 16 个库都挂在 ScriptGlobals 上

下一步:第 12 章 脚本运行时与脚本形态 —— 脚本怎么挂到工程上、什么时候被触发。