17 · 宿主、进程与壳

相关:第 10 章 运行数据引擎 · 第 15 章 自动化 API 总览

这一章是什么

前面几章讲的都是"库" —— 你把它们引进自己的程序里用。 这一章讲已经装好的那套东西:组态器的窗口、它怎么和网页前端说话、怎么把运行内核拉起来。

什么时候你会需要看这一章:

  • 你想知道组态器界面上的按钮点下去,后端到底发生了什么
  • 你要给前端加一个新的 RPC 方法(前端发消息、后端接住)
  • 你要理解"为什么改完点会自动保存"
  • 你要自己做一套壳,把前端页面嵌进自己的程序

这一章涉及 4 个程序集:

程序集 角色 一句话
Hmi.Shell WPF 窗口壳 一个窗口 + 一个 WebView2,里面跑前端页面
Hmi.HostBridge 消息路由 前端发来的 JSON 在这里分派给各个处理者
Hmi.RuntimeCore.Host 运行内核进程 一个独立 exe,专门跑工程
Hmi.Supervisor 进程监督 拉起子进程、看它活着没、崩了重启

一、组态器长什么样

┌─ Hmi.Shell.exe(WPF 窗口)─────────────────────────┐
│                                                  │
│   ┌─ WebView2 ──────────────────────────────┐    │
│   │                                         │    │
│   │   frontend/ 那一堆 HTML/JS/CSS          │    │
│   │   (就是你在浏览器里看到的那套界面)      │    │
│   │                                         │    │
│   └─────────────────────────────────────────┘    │
│                    ↕  消息(JSON)                │
│   ┌─ Hmi.HostBridge ────────────────────────┐    │
│   │  WebMessageRouter → 各个 RpcDispatcher   │    │
│   └─────────────────────────────────────────┘    │
│                    ↕                             │
│   Hmi.Automation / Hmi.Backend / ...             │
└──────────────────────────────────────────────────┘

关键点:前端页面和后端不在同一个进程里跑同一套对象 —— 它们是两个世界, 中间靠发 JSON 消息沟通。这条边界很重要,后面所有设计都是围绕它来的。

前端页面是怎么被加载的

不是开个本地 HTTP 服务器再访问 http://localhost:xxxx —— 那样要占端口、可能被防火墙拦。

实际做法是 WebView2 的虚拟域名映射:

await Web.EnsureCoreWebView2Async();
Web.CoreWebView2.SetVirtualHostNameToFolderMapping(
    "hmi.app.invalid",      // 虚拟主机名(故意用 .invalid 顶级域,保证不会和真域名撞)
    webDir,                 // 前端文件所在目录
    CoreWebView2HostResourceAccessKind.Allow);
Web.CoreWebView2.WebMessageReceived += OnWebMessage;
Web.CoreWebView2.Navigate("https://hmi.app.invalid/index.html");

这样前端页面看到的是一个正常的 https:// 来源(有安全上下文,能用 Web Worker、IndexedDB 等), 但实际读的是本地文件夹,不开任何端口。. 用 .invalid 是故意的 —— 这个顶级域被保留、永远不会被注册,不可能和真实网站撞名。

开哪个页面由命令行决定

参数 效果
不带参数 打开 index.html(组态器界面)
--runtime / -r / /runtime 打开 runtime.html(运行态界面)

环境变量:HMI_PROJECT_DIR(工程目录)、HMI_WEB_DIR(前端目录)、HMI_MODE。

不带参数时默认打开「文档\鲲鹏恒控\项目\示例工程」。


二、消息路由:前端怎么和后端说话

2.1 消息长什么样

所有消息都是同一个信封结构(WebMessage):

字段 类型 意思
Id string? 消息编号,用来把"回答"对上"提问"
Kind string 消息种类,见下表
Method string? 要调的方法名(Kind=rpc 时用)
Topic string? 话题名(Kind=event/sub 时用)
Ok bool? 成功没有(Kind=rpc-result 时用)
Message string? 人看的说明文字
Payload JsonElement? 数据本体

Kind 有 5 种:

Kind 方向 意思
rpc 前端 → 后端 "帮我做件事"
rpc-result 后端 → 前端 "做完了,结果在这"
event 后端 → 前端 "出事了,通知你一声"(不需要你请求)
sub 前端 → 后端 "我要订阅这些点"
unsub 前端 → 后端 "取消订阅"

⭐ 两套序列化规则 —— 这是最容易踩的坑

用在哪 命名风格 例子
信封本身 + 事件推送 camelCase {"id":"1","kind":"rpc"}
rpc-result 的 payload(工程模型对象) PascalCase {"Name":"主画面","Width":1280}

为什么两套:工程模型对象是和磁盘上的 project.json 共用同一套序列化配置的 —— 必须和文件里的 PascalCase 一致。而信封是纯通信协议,按 Web 习惯用 camelCase。

踩坑表现:混了的话,反序列化会静默产出空对象,而且 ok 还是 true。 你以为成功了,其实什么都没改。

2.2 路由怎么找处理者

前端发来 {"kind":"rpc","method":"add_tag",...},后端按这个顺序找:

① 路由器内建的一批方法(pick_file / launch_runtime / stop_runtime / preview_start / build_project …)
        ↓ 没匹配
② HistoryQueryBridge.Handles   → query_history / aggregate_history
        ↓ 没匹配
③ AuthRpcDispatcher.Handles    → auth_login / list_users / save_role …
        ↓ 没匹配
④ AiRpcDispatcher.Handles      → ai_chat / ai_list_providers …
        ↓ 没匹配
⑤ McpControlRpcDispatcher.Handles → mcp_start / mcp_stop / mcp_status …
        ↓ 没匹配
⑥ AutomationRpcDispatcher      → 剩下全部组态方法(约 100 个)
        ↓ 还没匹配
⑦ 回失败:未知方法:xxx

不是按前缀分派的 —— 是一张张显式的方法名清单。所以加一个新 RPC, 你要在对应的 dispatcher 里明确写出方法名,不会有"自动按前缀兜住"这种事。

2.3 路由器里被显式拦下的方法

有几个方法名在路由器层就被拦下了,不会往下走:

① 明确回"阶段六未开通"的(运行态写值类,刻意不开):

方法名 为什么拦
runtime_write_tag 写 PLC 有物理后果,不经这条路
download_recipe 运行态下发配方
capture_recipe 运行态读回配方
report_get / report_record_click 运行态报表

② 需要宿主帮忙的(要弹文件框、要起进程,这些只有壳做得到):

方法名 干什么
pick_project_dir 弹"选择文件夹"框
pick_file / pick_save_file 弹"打开/保存文件"框
import_publish_icon 选发布用的图标
launch_runtime 拉起运行态
stop_runtime 停掉编辑器拉起的运行态进程(先请它体面退出跑完工程关闭脚本,超时再强杀进程树)
open_project_folder 在资源管理器里打开工程目录
preview_start / preview_stop 画面预览的起停
run_script 跑脚本
build_project 编译成 exe(只有完整模式:quick != true 且指定了 outDir)
get_ui_overrides 读界面覆盖配置
write_log 前端日志 → 写进 UiLog

③ 兜底文案:一个都没匹配上时,回的失败信息是:

未知方法:xxx(运行态写值 runtime_write_tag 不经此路由)。

2.4 后端怎么把结果送回去

两个方向:

// 回一个 rpc-result(payload 用 PascalCase)
private void Reply(string id, AutomationResult r) { ... }

// 主动推一个事件(payload 用 camelCase)
public void PushEvent(string topic, object payload) { ... }

2.5 IWebMessageSink —— 出站的唯一出口

接口只有一个方法:

public interface IWebMessageSink
{
    void Post(string json);   // 往前端发一段 JSON
}

Hmi.Shell 里的实现是 WebView2Sink:

public WebView2Sink(WebView2 web) { ... }

public void Post(string json)
{
    // 在 UI 线程就直接发,否则切过去再发
    if (onUiThread) _web.CoreWebView2?.PostWebMessageAsJson(json);
    else _web.Dispatcher.BeginInvoke(() => _web.CoreWebView2?.PostWebMessageAsJson(json));
}

为什么要有这个接口:路由器不知道前端是 WebView2 还是浏览器还是别的什么。 抽出一个"只要能发字符串"的接口,以后换成别的宿主(比如换成 Electron、或者做成 WebSocket 服务)不用改路由器。 测试里也可以塞一个假的 sink 把消息收下来断言。

⚠ CoreWebView2 还没就绪时消息会被丢掉(注意那个 ?.)。所以壳里要等 EnsureCoreWebView2Async 完成再开始收消息。


三、实时值是怎么推到界面上的

界面上的数值要实时刷新。不可能让前端每秒发几百个请求问一遍 —— 那太浪费。 实际做法是订阅 + 推送。

3.1 订阅

前端发:

{ "kind": "sub", "topic": "tag-update", "payload": { "tags": ["Oven.Temp", "Oven.Press"] } }

后端 TagSubscriptionBridge:

public TagSubscriptionBridge(RtdbCore rtdb, IReadOnlyDictionary<string,int> idByName, IWebMessageSink sink);

public void Subscribe(IEnumerable<string> names);      // 订阅
public void Unsubscribe(IEnumerable<string> names);    // 取消
public void OnTagChanged(ReadOnlySpan<TagChange> batch);  // 由实时库回调
public void Dispose();

订阅成功会立刻补一帧快照 —— 这样前端马上就有值显示,不用等第一次变化。

3.2 推送的形状

{
  "kind": "event",
  "topic": "tag-update",
  "payload": {
    "tags": [
      { "name": "Oven.Temp", "value": 182.5, "quality": "Good", "ts": 1758888888888, "type": "Float" }
    ]
  }
}
字段 说明
name 点名
value 值。模拟量走 .D(double),离散量走 .L(long) —— 注意类型会变
quality 质量,字符串形式(不是数字)
ts 时间戳,毫秒 epoch(不是 tick,不是秒)
type 点类型名(Bool / Int16 / Float / …)。两态控件按点域写值用:Bool 点写 "1"/"0",数值点写 "2"/"1"(两态口径是 2=开/1=关,而 Bool 装不下 2)

⚠ 前端解析时别假设 value 一定是数字 —— 布尔点会来一个 0/1 的 long。

3.3 背压:推不动就丢

TagSubscriptionBridge 内部有个有界队列(容量 8192),策略是 DropOldest。

意思是:如果前端处理不过来、消息堆积超过 8192 条,就把最老的丢掉。

这是故意的,不是 bug:实时值这种东西,最老的数据没意义,保住"最新的"才是对的。 但它也意味着 —— 界面上的值可能会跳变(中间某些帧被丢了)。做趋势曲线要用历史库,不要靠订阅攒数据。

3.4 历史查询走单独的桥

HistoryQueryBridge 认两个方法:

方法名 参数 返回
query_history tagName, fromMs, toMs, maxPoints? { TagName, Points:[{TsMs,Value,Quality}], Count, Truncated }
aggregate_history 同上 + func { TagName, Func, Value }

func 只能是:Avg / Max / Min / Sum / Count。

maxPoints 是抽稀用的:查一天的数据可能有 86400 个点,给界面画曲线只要几百个。 传了 maxPoints,后端会抽稀,并把 Truncated 标成 true 告诉你"数据被抽过了"。


四、自动保存:改完为什么不用点保存

Hmi.Shell 里有个 AutoSaveCoordinator:

public AutoSaveCoordinator(
    Dispatcher dispatcher,
    Func<AutomationResult> save,     // 怎么保存
    Action<string> log,              // 保存了往哪写日志
    TimeSpan? debounce = null);      // 防抖间隔,默认 1.5 秒

public void Bump();    // 标脏 + 重新排期(任意线程都能调)
public void Flush();   // 立刻落盘

怎么判断"该保存了"

自动化 API 每次改成功后会发一个 ProjectMutated 信号,协调器接住后:

  1. 只处理成功的操作
  2. 只处理会落盘的操作 —— IsPersistingMutation 判断
  3. 排除 get_ / list_ / describe_ 开头的方法(这些是只读)
  4. 再排除一张 12 个方法的"不落盘"名单

防抖 1.5 秒是什么意思

你连着改 10 个属性 → 不会存 10 次盘。 每次改动都把计时器往后推 1.5 秒,直到停手 1.5 秒才真正写一次磁盘。

为什么这么做:写盘是有成本的(序列化整个工程 + 文件 I/O)。 拖着鼠标拖一个控件会触发几十次改动,不防抖的话会卡死。

⚠ 只有编辑器装配这个(--runtime 模式不装配)。运行态改的是内存,不碰工程文件。


五、运行内核进程:Hmi.RuntimeCore.Host

这是个独立的 exe,专门用来跑工程。为什么要单独一个进程?

好处 说明
崩了不影响编辑器 运行内核崩了,组态器还活着,可以重连
可以单独发给客户 客户机器上不用装组态器,只放运行内核 + 加密工程包
能整树杀掉 脚本死循环、驱动卡住,直接杀进程树

5.1 命令行

Hmi.RuntimeCore.Host.exe <工程目录> [--crash-after-ready <毫秒>]
参数 说明
第 1 个位置参数 工程目录(必填)。缺了直接打 RUNTIMECORE FAILED 并以退出码 2 结束
--crash-after-ready <ms> 测试用的:就绪后等这么多毫秒故意崩

⚠ 没有环境变量兜底 —— 必须给位置参数。别指望 HMI_PROJECT_DIR 之类的东西。

它的 stdout 有两句约定好的话(监督者就靠这个判活):

打印内容 什么时候
RUNTIMECORE READY 装配工程 + 起采集成功,恰好一行
HEARTBEAT <n> 之后每秒一行,n 递增

💡 ManagedProcess.IsStalled(timeout) 判的就是"多久没收到 stdout 任何一行了"。 所以心跳停了 = 采集循环卡住了 —— 这比"进程还在不在"更早发现问题。

5.2 它启动了什么

ProjectLoader.Load(工程目录)      → 读出工程
        ↓
new RuntimeHost(工程)             → 建实时库、起采集循环
        ↓
RuntimeDataServices.Compose(...)  → 叠上历史库、报警、脚本
        ↓
host.Start()
        ↓
StartScriptHost()                 → 起脚本运行时

5.3 怎么和宿主通信 —— stdio

不是命名管道,不是 socket —— 就是最朴素的标准输入输出:

通道 用途
stdout 控制面(就绪信号、心跳)
stderr 日志

5.4 就绪信号与心跳

启动完成后,往 stdout 打恰好一行:

RUNTIMECORE READY

之后每秒打一行:

HEARTBEAT <次数>

为什么用"恰好一行"这么严格:父进程靠读这一行判断"起来了没有"。 如果前面多打了别的东西(比如某个库的欢迎语),父进程就可能认不出来,一直等下去。

5.5 退出码

码 意思
0 干净停机
1 装配异常(会往 stderr 打 RUNTIMECORE FAILED: …)
2 缺参数(没给工程目录,或目录不存在)

5.6 什么时候会停

  • 收到 Ctrl+C
  • 进程收到退出通知(ProcessExit)
  • stdin 读到 EOF ← 这条很关键:父进程一关,stdin 就断了,子进程自己跟着退,不会变孤儿

5.7 运行态日志怎么回到组态器

运行内核是个独立进程,它的日志要显示在组态器的"输出"面板里。做法是打一个哨兵前缀:

子进程往 stdout 写:

##RTLOG##{"level":"warn","text":"烤箱温度偏高","source":"脚本","ts":1758888888888}

父进程读每一行时先看开头:

  • 命中 ##RTLOG## → 拆出 JSON → PushEvent("runtime-log", ...) 推给前端
  • 没命中 → 当普通控制面消息处理(比如 HEARTBEAT)

为什么要这么绕:stdout 是混用的 —— 既要传控制信号,又要传日志。 加个不可能自然出现的哨兵前缀,就能把两类消息分开。

哨兵常量定义在 RuntimeLogWire.Sentinel,别在两处各写一遍字面量。


六、进程监督:Hmi.Supervisor

什么时候用:你有一个"主程序 + 一堆配套服务"的结构(比如组态器要同时跑运行内核、 MCP 服务器、数据采集服务),想让主程序统一管它们的生死。

6.1 ServiceSpec —— 描述一个服务

public sealed class ServiceSpec
{
    public ServiceSpec(string name, string exePath);   // 名字和 exe 路径是必填的

    public string Name { get; }              // 只读
    public string ExePath { get; }           // 只读
    public string Arguments { get; init; } = "";
    public string? WorkingDirectory { get; init; }
    public string? ReadyMarker { get; init; }        // 看到这行才算"就绪"
    public TimeSpan ReadyTimeout { get; init; } = TimeSpan.FromSeconds(10);
    public bool AutoRestart { get; init; } = false;  // 崩了自动重启
    public int MaxRestarts { get; init; } = 3;       // 最多重启几次
}

6.2 ReadyMarker —— 怎么判断"起来了"

子进程的 stdout 只要包含 ReadyMarker 这个子串,就标记为就绪。

  • ReadyMarker 留空 → 进程一起来就算就绪
  • 有值 → 等它打出这句话

所以 RuntimeCore.Host 那句 RUNTIMECORE READY 就是给这里用的。

6.3 ManagedProcess —— 管一个进程

public sealed class ManagedProcess : IDisposable
{
    public ManagedProcess(ServiceSpec spec);

    public ServiceSpec Spec { get; }
    public int ProcessId { get; } = -1;      // 没起来时是 -1
    public bool HasExited { get; }
    public Action<ManagedProcess,int>? Crashed { get; set; }   // 崩了通知你(带退出码)

    public Task StartAsync(CancellationToken ct = default);
    public bool IsStalled(TimeSpan timeout);   // 多久没输出了?
    public void Stop(TimeSpan timeout);
    public void Dispose();
}

日志收集:stdout 和 stderr 都被重定向(RedirectStandardOutput/Error = true), 任何一行输出都会刷新心跳时间。

所以 IsStalled 的判据是"多久没输出任何东西",不是"进程还在不在"。 一个卡死的进程照样活着,但不再输出 —— 这才叫 stalled。

6.4 ProcessSupervisor —— 管一群进程

public sealed class ProcessSupervisor : IDisposable
{
    public Action<ServiceSpec,int>? ServiceAbandoned { get; set; }  // 放弃重启时通知

    public IReadOnlyCollection<string> RunningServices { get; }
    public bool IsRunning(string name);

    public Task StartSessionAsync(IEnumerable<ServiceSpec> specs, CancellationToken ct = default);
    public Task StopSessionAsync(TimeSpan? timeout = null);   // 默认 5 秒
    public void Dispose();
}

重启策略:

子进程崩了
    ↓
AutoRestart == false ?
    → 触发 ServiceAbandoned,不再管它
    ↓ 是
重启次数 > MaxRestarts ?
    → 触发 ServiceAbandoned,放弃
    ↓ 否
拉起一个新的 ManagedProcess,计数 +1

停止用的是 Kill(entireProcessTree: true) —— 整棵进程树一起杀。

这条很重要:一个服务可能自己又拉起了子进程。只杀父进程会留下孤儿。 整树杀才是干净的。

6.5 一个完整例子

using Hmi.Supervisor;

using var sup = new ProcessSupervisor();

sup.ServiceAbandoned += (spec, code) =>
    Console.WriteLine($"服务 {spec.Name} 崩了(退出码 {code}),已放弃重启");

var specs = new[]
{
    // ⭐ 构造签名是 (服务名, exe 完整路径) —— 名字在前,别把路径塞进第一个参数
    new ServiceSpec("RuntimeCore", @"C:\app\runtime\Hmi.RuntimeCore.Host.exe")
    {
        Arguments    = @"D:\工程\产线",          // 运行内核把工程目录当第一个参数
        ReadyMarker  = "RUNTIMECORE READY",     // 它在 stdout 打这一行(精确串)
        ReadyTimeout = TimeSpan.FromSeconds(20),
        AutoRestart  = true,
        MaxRestarts  = 5,
    },
    new ServiceSpec("McpHost", @"C:\app\mcp\Hmi.Mcp.Host.exe")
    {
        Arguments = @"D:\工程\产线 --http 127.0.0.1 8765",
        // ⚠ 这里**故意不设 ReadyMarker** —— 原因见下面的坑
    },
};

await sup.StartSessionAsync(specs);
Console.WriteLine("都在跑了");

// ... 跑一阵 ...

await sup.StopSessionAsync();          // 默认等 5 秒;参数名是 waitForExit

⚠ ReadyMarker 只在子进程的 stdout 里找,不认 stderr。 Hmi.RuntimeCore.Host 就绪时往 stdout 打 RUNTIMECORE READY,所以能用; 而 Hmi.Mcp.Host 把日志全写在 stderr(stdout 留给 JSON-RPC 协议流), 给它设 ReadyMarker 会永远等不到 → 超时抛异常。所以要么不设(进程起来即算就绪), 要么改走 §7 的 SystemMcpServerProcess(它用"起后等 600 毫秒看有没有立刻死"这套判断)。


七、MCP 服务器子进程:组态器里怎么管它

组态器界面里有个「工具 → MCP 服务器」面板,可以一键启停 MCP 服务器。 底层是这几个东西:

7.1 配置存哪

McpServerConfigStore:

public McpServerConfigStore(string? path = null);
// 默认路径:%APPDATA%\FangLikong\mcp-server.json

public string Raw();               // 读原始 JSON 文本
public void Save(string? json);    // 写
public McpServerConfig Parse();    // 解析成对象

McpServerConfig 字段:

字段 默认值 意思
Transport "stdio" stdio 还是 http
HttpHost "127.0.0.1" http 监听地址
HttpPort 8765 http 监听端口
AuthToken "" 访问口令(空 = 不校验)
ExposeTools — 只暴露这些工具(空 = 全部)
ReadOnly false 只读模式

HttpEndpoint 是算出来的:http://{Host}:{Port}/mcp。

7.2 怎么拉起来

接口叫 IMcpServerProcess,实现叫 SystemMcpServerProcess:

public interface IMcpServerProcess
{
    bool IsRunning { get; }
    int Pid { get; }
    string Start(string projectDir, McpServerConfig cfg);
    void Stop();
}

public SystemMcpServerProcess(string? exePath);

它拼出来的命令行是:

Hmi.Mcp.Host.exe <工程目录> --http <host> <port> [--token xxx] [--read-only] [--tools a,b,c]

⚠ 起来之后会 WaitForExit(600) —— 等 600 毫秒看它有没有立刻死掉。 这是为了抓"参数写错了、启动就退"这种情况,不然会显示成"启动成功"但实际早没了。

7.3 前端用的 RPC

方法名 干什么
mcp_status 查状态
mcp_start 启动
mcp_stop 停止
mcp_get_config 读配置
mcp_set_config 写配置

mcp_status 返回的字段:Running / ToolCount / Transport / Endpoint / Pid / Message。

✅ ToolCount 曾有一个偏差,2026-09-26 已修:McpControlRpcDispatcher 里那个 "面板上显示多少个工具"的常量曾经写 79、而 ProjectMcpTools 实际有 84 个,导致面板少报 5 个。 现在常量已是 84,并加了一条测试(McpToolShapeTests)钉住两者必须相等。 如果你哪天加/删了 MCP 工具,除了改文档,记得回头改那个常量 —— 测试会立刻变红提醒你。


八、界面缩放记在哪

UiZoomStore —— 记住你上次把界面放大/缩小到多少:

public void Load(string role);              // role: "editor" 或 "runtime"
public void Save(string role, double zoom);
// 存到 %APPDATA%\FangLikong\ui-zoom.json,值会被夹在 0.25 ~ 5.0

它接到 WebView2.ZoomFactor 上,并通过 ZoomFactorChanged 事件回写。

编辑器和运行态各存一份 —— 你在编辑器里放大到 150%,运行态不会跟着变。


九、两个日志文件别搞混

类 程序集 落哪 特点
ShellLog Hmi.Shell exe 同目录 shell.log 只记壳自己的事(启动、WebView2 初始化)。任何失败静默
UiLog Hmi.HostBridge %APPDATA%\FangLikong\ui.log 前端 write_log 打过来的日志。超过 4MB 轮转成 ui.log.1

ShellLog 的"任何失败静默"是故意的:日志系统自己都写不进去的时候, 不该再抛异常把程序带崩。日志是尽力而为的东西。


十、这一章的几个坑

# 坑 怎么避
1 信封和 payload 命名风格搞混 信封 + 事件用 camelCase;rpc-result 的 payload 用 PascalCase
2 CoreWebView2 没就绪就发消息 消息会被静默丢弃。等 EnsureCoreWebView2Async 完成
3 忘了 IsPersistingMutation 新加只读方法时,名字要用 get_/list_/describe_ 开头,否则会触发多余的自动保存
4 靠订阅攒趋势数据 队列满了会 DropOldest,值会跳变。要完整数据用历史库
5 ts 当成秒或 tick 是毫秒 epoch
6 value 假设一定是 double 离散量是 long(.L)
7 只杀父进程 用 Kill(entireProcessTree: true)
8 ReadyMarker 拼错 拼错就永远等不到就绪,超时后失败。要和子进程打印的那行逐字符一致
9 stdout 混打非协议内容 就绪判定是"恰好一行 RUNTIMECORE READY",前面多打东西会认不出来

本章内容以 src/Hmi.Shell、src/Hmi.HostBridge、src/Hmi.Supervisor、 src/Hmi.RuntimeCore.Host 为准。发现手册和代码对不上,以代码为准,并回头修手册。