09 · 写一个自己的协议插件

相关:第 07 章 驱动是怎么工作的 · 模板:examples/DemoProtocolPlugin/

什么时候你会需要它

  • 你要接一台自研设备,用的是自家协议
  • 你要接一台冷门 PLC,内置协议里没有
  • 你想把某个内部专有协议封装起来给同事用

不需要改主程序 —— 放一个目录进去就能用。这一章带你从零做一个能加载的插件。

插件机制是怎么运作的

启动
  │
  v
DriverFactory.BuildRegistry(pluginsDir)
  │
  ├─ 1. 注册内置协议(8 个)
  │
  └─ 2. ProtocolPluginLoader.LoadAll() 扫描 pluginsDir
         │
         对每个子目录:
         ├─ 读 plugin.json          ← 缺了直接跳过
         ├─ 校验 abi == 1           ← 不等就拒绝,提示"请更新协议包"
         ├─ 建独立 AssemblyLoadContext(可卸载)
         ├─ 反射找 IProtocolPackage 实现并实例化
         └─ 注册进 ProtocolRegistry(ID 冲突时后注册者被拒)

五个要点:

  1. 一个子目录 = 一个协议包,目录里放 plugin.json + 编译好的 dll。
  2. ABI 版本必须匹配。当前 ABI 版本是 1。不匹配会被拒绝加载(并给出提示)。
  3. 每个包一个独立的加载上下文 —— 不同插件用不同版本的同一个库也不会打架。 ⚠ 但不能热重载:没有代码去触发那个上下文回收,dll 一直被文件锁着(见「实测记录 ①」)。
  4. ID 冲突时内置优先,后注册的被拒(错误记在 DriverFactory.PluginErrors 里)。
  5. 一个包加载失败不影响其它包,也不会让主程序崩。

⚠ 插件是进程内全信任的代码,不是沙箱。 所以插件目录必须是你信任的安装目录, 别让用户随便往里丢 dll。

五步做一个插件

第 1 步:建项目

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <!-- 只需要引用这一个契约程序集 -->
    <ProjectReference Include="..\..\src\Hmi.Drivers\Drivers.Abstractions\Drivers.Abstractions.csproj" />
  </ItemGroup>
</Project>

只引用 Drivers.Abstractions(它会带上 Hmi.Core)。不要引用 Hmi.Drivers 本体或其它程序集 —— 引用多了容易和宿主版本冲突。

第 2 步:实现包入口 IProtocolPackage

这是插件的唯一入口,加载器就是靠找它来实例化的。

using Hmi.Drivers.Abstractions;
using Hmi.Drivers.Abstractions.Plugins;

namespace 我的协议插件;

public sealed class 我的协议包 : IProtocolPackage
{
    public string ProtocolId => "My.Device";          // 通道里填这个
    public string DisplayName => "我的自研设备协议";    // 界面上显示的名字
    public string Version => "1.0.0";
    public string? AddressSyntaxHelp => "填寄存器序号,如 0 / 10 / 100。";

    public IProtocolDriver CreateDriver() => new 我的驱动();
    public IAddressParser CreateAddressParser() => new 我的地址解析器();
}

完整接口:

public interface IProtocolPackage
{
    string ProtocolId { get; }          // 唯一标识,通道的 DriverName 填这个
    string DisplayName { get; }         // 给人看的名字
    string Version { get; }
    string? AddressSyntaxHelp { get; }  // 地址写法提示(会显示给用户)
    IProtocolDriver CreateDriver();
    IAddressParser CreateAddressParser();
}

⚠ ProtocolId 必须和 plugin.json 里的 id 完全一致(区分大小写)。 不一致会加载失败。

第 3 步:实现驱动 IProtocolDriver

这是真正干活的地方 —— 把字节读回来。

using Hmi.Drivers.Abstractions;
using Hmi.Drivers.Abstractions.Messages;
using Hmi.Drivers.Abstractions.Models;

public sealed class 我的驱动 : IProtocolDriver
{
    public string Name => "My.Device";
    public ProtocolLimits Limits { get; } = new();   // 单次读写上限,默认值够用
    public bool IsConnected { get; private set; }

    private TcpClient? _tcp;

    public async Task ConnectAsync(DeviceConfig dev, CancellationToken ct)
    {
        // dev.Endpoint / dev.ConnectionKind / dev.ConnectionParams 里是设备配置
        string host = dev.ConnectionParams["host"];
        int port = int.Parse(dev.ConnectionParams["port"]);

        _tcp = new TcpClient();
        await _tcp.ConnectAsync(host, port, ct);
        IsConnected = true;
    }

    public void Disconnect()
    {
        _tcp?.Dispose();
        _tcp = null;
        IsConnected = false;
    }

    public async Task<ReadResult> ReadBlockAsync(ReadBlock block, CancellationToken ct)
    {
        // block 里是:起始地址、长度、块内每个点的落位
        // 你的任务:按自家协议发请求,把返回的字节原样交出去
        byte[] raw = await 发请求并收字节(block, ct);

        // 成功
        return ReadResult.Success(raw);

        // 失败(注意:不抛异常,返回 Failure)
        // return ReadResult.Failure(Quality.CommFailure);
    }

    public async Task WriteAsync(WriteRequest req, CancellationToken ct)
    {
        // 写单个值到设备
    }

    public async Task WriteManyAsync(IReadOnlyList<WriteRequest> reqs, CancellationToken ct)
    {
        // 批量写。没有批量能力就逐个调 WriteAsync
        foreach (var r in reqs) await WriteAsync(r, ct);
    }

    public void Dispose() => Disconnect();
}

完整接口:

public interface IProtocolDriver : IDisposable
{
    string Name { get; }
    ProtocolLimits Limits { get; }
    bool IsConnected { get; }
    int MaxConcurrentRequests => 1;   // 默认 1(串行);支持管线化才改大

    Task ConnectAsync(DeviceConfig dev, CancellationToken ct);
    void Disconnect();
    Task<ReadResult> ReadBlockAsync(ReadBlock block, CancellationToken ct);
    Task WriteAsync(WriteRequest req, CancellationToken ct);
    Task WriteManyAsync(IReadOnlyList<WriteRequest> reqs, CancellationToken ct);
}

几条硬规矩:

规矩 为什么
读失败返回 ReadResult.Failure,不抛异常 采集是长期循环,一次失败不该让通道崩
停机时的 OperationCanceledException 必须上抛 吞掉会导致进程退不干净
如果 ReadResult.Raw 来自缓冲池,必须能通过 Release 归还 否则内存一直涨
MaxConcurrentRequests > 1 时,ReadBlockAsync 必须可重入 框架会并发调它

第 4 步:实现地址解析 IAddressParser

把用户填的地址字符串,解析成结构化的地址。

using Hmi.Core.Models;
using Hmi.Drivers.Abstractions;
using Hmi.Drivers.Abstractions.Models;
using Hmi.Drivers.Abstractions.Parsing;

public sealed class 我的地址解析器 : IAddressParser
{
    public ParsedAddress Parse(Tag tag)
    {
        string s = tag.Address.Trim();
        if (s.Length == 0) throw new FormatException($"点 {tag.Name} 地址为空");

        if (!int.TryParse(s, out int offset) || offset < 0)
            throw new FormatException($"点 {tag.Name} 地址无法解析: {tag.Address}");

        // 参数:区名 / 区内偏移 / 位号(-1=不取位) / 是否位区 / 跨度 / 每单位字节数
        int span = RegisterWidth.Registers(tag.Type);
        return new ParsedAddress("R", offset, bit: -1, isBitArea: false,
                                 span: span, bytesPerUnit: 2);
    }
}

ParsedAddress 的六个字段:

字段 含义
Area 区名(如 "R"、"D"、"HoldingRegister")
Offset 区内偏移
Bit 位号,-1 表示不取位
IsBitArea 是不是位区。影响块读规划 —— 字读和位读不能混进同一块
Span 跨度(占几个单位)
BytesPerUnit 每个单位几字节(寄存器 = 2,字节寻址 = 1)

💡 RegisterWidth 帮你算跨度:RegisterWidth.Registers(type) 按寄存器数算, RegisterWidth.Bytes(type) 按字节数算。别自己写 switch。

第 5 步(可选):声明连接方式

如果设备支持串口,实现这个接口让界面自动长出串口参数输入框:

using Hmi.Drivers.Abstractions.Plugins;

public sealed class 我的协议包 : IProtocolPackage, IConnectionSchemaProvider
{
    // …前面那些…

    // ⚠ 两个都是**方法**,不是属性 —— Tcp 还要求传默认端口。
    //   写成 TransportSchemas.Tcp 直接编不过(方法组不能当 TransportSchema 用)。
    public IReadOnlyList<TransportSchema> Transports => new[]
    {
        TransportSchemas.Tcp(defaultPort: 502),   // 预置的 TCP 方案:IP 地址 + 端口
        TransportSchemas.Serial(),                // 预置的串口方案:COM + 波特率/校验/数据位/停止位
    };
}

两个预置方案各自带哪些参数(向导就照这个渲染表单,名字是驱动取值的键):

方案 参数(Name)
TransportSchemas.Tcp(defaultPort) host、port
TransportSchemas.Serial() com、baud、parity、dataBits、stopBits

不实现这个接口也能用 —— 会兜底成"只有 TCP(host+port)"。

💡 这就是第 05 章说的"设备表单数据驱动": 你在这里声明支持什么连接方式,组态器的设备弹窗就会渲染出对应的输入框,不用改前端一行代码。

最后:写 plugin.json 并放好文件

{
  "id": "My.Device",
  "displayName": "我的自研设备协议",
  "version": "1.0.0",
  "abi": 1,
  "entryAssembly": "我的协议插件.dll"
}
字段 含义 注意
id 协议 ID 必须和代码里的 ProtocolId 完全一致(区分大小写)
displayName 显示名 界面用
version 插件版本 自己的版本号,和 ABI 无关
abi 契约版本 当前必须是 1。不匹配会被拒绝加载
entryAssembly 入口 dll 文件名 相对插件目录

目录结构:

plugins/                          ← 插件根目录(传给 DriverFactory.BuildRegistry)
└── 我的协议/                      ← 一个子目录 = 一个包
    ├── plugin.json
    ├── 我的协议插件.dll
    └── (这个包自己的依赖 dll)

调试与排查

下表左列是加载器实际会打出来的错误串(可以直接对号入座,不用猜):

你会看到 真因
缺少 plugin.json 清单 那个子目录里没放 plugin.json
协议包 X 的 ABI=n 与本软件 ABI=1 不兼容,请更新协议包 abi 不是 1
协议包 X 未指定 entryAssembly plugin.json 里漏了 entryAssembly
入口程序集不存在:xxx.dll entryAssembly 写的文件名在目录里找不到
协议包 X 未找到 IProtocolPackage 实现 入口 dll 里没有 public 非抽象的 IProtocolPackage 实现类(见下面坑 #2)
清单 id='a' 与包内 ProtocolId='b' 不一致 plugin.json 的 id 和代码里的 ProtocolId 不一致(区分大小写)
协议 X 与已有协议 ID 冲突,插件被忽略 ID 撞了内置协议或另一个插件(内置优先)
plugin.json 解析失败:… JSON 写坏了
界面上找不到这个协议,但也没报错 插件根目录传错了(BuildRegistry 的那个参数)

加载失败不会让程序崩,只会让这个包不可用。所以出问题时先去看 DriverFactory.PluginErrors,别瞎猜。

💡 plugin.json 容错:解析时允许注释和尾逗号(JsonCommentHandling.Skip + AllowTrailingCommas)。所以你可以这么写,不用担心: jsonc { // 通道里 DriverName 填这个 "id": "My.Device", "abi": 1, // ← 尾逗号也认 }

实测记录(2026-09-26)

这一章早先是照代码写的、没跑起来验证过。下面是真编译真加载跑出来的结论, 每条都有对应的测试钉在 tests/Hmi.Drivers.Tests/PluginDocContractTests.cs。

✅ 验证通过的

  • 示例包 examples/DemoProtocolPlugin/ 编译后只靠 plugin.json + 一个 dll 就能被加载、 能造出 driver 和 parser、能注册进注册表并按 ID 解析出来。
  • 上面那张错误表里的每一条,文案都对得上。
  • 一个坏包不影响好包(同一个插件根下,一个 JSON 写坏的包失败,另一个照常加载)。
  • plugin.json 认注释与尾逗号。

⚠ 三个要知道的后果

① 插件 dll 一旦加载就被文件锁住 —— 升级插件必须重启软件。

那个独立的 AssemblyLoadContext 虽然声明了 isCollectible: true,但全仓库没有任何地方调 Unload(),所以回收不会发生,锁也不会释放。直接覆盖 dll 会报"文件被占用"。 换插件 = 关掉软件、换文件、再开。

② 契约 dll 混进插件目录曾经会加载失败(已修)。

dotnet build 的输出目录 bin/Debug/net8.0/ 里天然带着 Hmi.Core.dll 和 Hmi.Drivers.Abstractions.dll。如果你图省事把整个 bin 拷进插件目录,插件里就会多出一份契约程序集, 于是出现"第二份 IProtocolPackage" —— 类型身份对不上,宿主那边强转全部失败。

修之前的报错是 未找到 IProtocolPackage 实现,完全指不到真因,能查一整天。 现在 PluginLoadContext 会显式短路这两个程序集(一律回落到宿主默认上下文),混进去也能正常加载。

不过还是建议只拷你自己那个 dll —— 少两个文件、少一份困惑。

③ 示例解析器曾经违反本章自己的规矩(已修)。

本章坑 #5 说"解析不出来宁可抛 FormatException,不要返回默认值", 但示例 EchoAddressParser 早期写的是 int.TryParse(...) ? n : 0 —— 解析失败静默当 0。 照模板抄的人会直接继承这个坑。现在已改成抛异常,与内置的三菱/松下/欧姆龙解析器一致。

踩过的坑

  1. id 大小写不一致 —— 校验是区分大小写的,My.Device ≠ my.device。
  2. 把 Hmi.Core 的 dll 也拷进插件目录 —— 曾经会让加载失败并报一句完全指不到真因的错误。 加载器现在已显式短路这两个程序集,但建议只拷自己的 dll。
  3. IsBitArea 没设对 —— 块读规划会把同组首点的 IsBitArea 应用到整组, 位读和字读混在一起会读出垃圾数据。
  4. 读失败时抛异常而不是返回 Failure —— 会让整个通道被判定为故障。
  5. 地址解析太宽松 —— 解析不出来时宁可抛 FormatException,也不要返回一个默认值。 静默读错位置比直接报错难查一百倍(内置的三菱/松下驱动就是这么做的)。
  6. 以为改完插件能热重载 —— 不能。dll 被锁到进程结束,换插件要重启软件(见上面 ①)。
  7. TransportSchemas.Tcp 忘了传端口 / 当成属性用 —— 它是方法,Tcp(defaultPort: 502)。

小结

  • 一个子目录 = 一个包,plugin.json + dll
  • 必须实现 IProtocolPackage,它返回 IProtocolDriver 和 IAddressParser
  • 可选实现 IConnectionSchemaProvider 声明连接方式(界面会自动适配); TransportSchemas.Tcp(defaultPort) / .Serial() 都是方法
  • ABI 版本必须等于 1,id 必须和 ProtocolId 一致(区分大小写)
  • 插件进程内全信任,放可信目录
  • 解析不出来就抛异常,别返回默认值
  • ⚠ 换插件要重启软件(dll 被加载上下文锁住,没有热重载)
  • 出问题先读 DriverFactory.PluginErrors,那里的中文错误能直接对号入座

完整可跑模板见 examples/DemoProtocolPlugin/(一个不接真设备的回环协议)。

本章的每条断言都有测试钉着:tests/Hmi.Drivers.Tests/PluginDocContractTests.cs。 改插件机制时,那套测试会告诉你哪些承诺被破坏了。

下一步:第 10 章 运行数据引擎 —— 把工程真正跑起来。