09 · 写一个自己的协议插件
相关:第 07 章 驱动是怎么工作的 · 模板:examples/DemoProtocolPlugin/
什么时候你会需要它
- 你要接一台自研设备,用的是自家协议
- 你要接一台冷门 PLC,内置协议里没有
- 你想把某个内部专有协议封装起来给同事用
不需要改主程序 —— 放一个目录进去就能用。这一章带你从零做一个能加载的插件。
插件机制是怎么运作的
启动
│
v
DriverFactory.BuildRegistry(pluginsDir)
│
├─ 1. 注册内置协议(8 个)
│
└─ 2. ProtocolPluginLoader.LoadAll() 扫描 pluginsDir
│
对每个子目录:
├─ 读 plugin.json ← 缺了直接跳过
├─ 校验 abi == 1 ← 不等就拒绝,提示"请更新协议包"
├─ 建独立 AssemblyLoadContext(可卸载)
├─ 反射找 IProtocolPackage 实现并实例化
└─ 注册进 ProtocolRegistry(ID 冲突时后注册者被拒)
五个要点:
- 一个子目录 = 一个协议包,目录里放
plugin.json+ 编译好的 dll。 - ABI 版本必须匹配。当前 ABI 版本是
1。不匹配会被拒绝加载(并给出提示)。 - 每个包一个独立的加载上下文 —— 不同插件用不同版本的同一个库也不会打架。 ⚠ 但不能热重载:没有代码去触发那个上下文回收,dll 一直被文件锁着(见「实测记录 ①」)。
- ID 冲突时内置优先,后注册的被拒(错误记在
DriverFactory.PluginErrors里)。 - 一个包加载失败不影响其它包,也不会让主程序崩。
⚠ 插件是进程内全信任的代码,不是沙箱。 所以插件目录必须是你信任的安装目录, 别让用户随便往里丢 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。
照模板抄的人会直接继承这个坑。现在已改成抛异常,与内置的三菱/松下/欧姆龙解析器一致。
踩过的坑
id大小写不一致 —— 校验是区分大小写的,My.Device≠my.device。- 把
Hmi.Core的 dll 也拷进插件目录 —— 曾经会让加载失败并报一句完全指不到真因的错误。 加载器现在已显式短路这两个程序集,但建议只拷自己的 dll。 IsBitArea没设对 —— 块读规划会把同组首点的IsBitArea应用到整组, 位读和字读混在一起会读出垃圾数据。- 读失败时抛异常而不是返回 Failure —— 会让整个通道被判定为故障。
- 地址解析太宽松 —— 解析不出来时宁可抛
FormatException,也不要返回一个默认值。 静默读错位置比直接报错难查一百倍(内置的三菱/松下驱动就是这么做的)。 - 以为改完插件能热重载 —— 不能。dll 被锁到进程结束,换插件要重启软件(见上面 ①)。
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 章 运行数据引擎 —— 把工程真正跑起来。