设备软件很少从零控制硬件。相机、运动控制卡、光源和传感器通常附带 C/C++ DLL、.NET 封装或通信手册。直接调用这些 API 可以很快点亮设备,却会把厂商枚举、句柄、错误码、线程限制和单位扩散到整个项目,最终让流程难以测试、硬件难以替换。
硬件抽象层(Hardware Abstraction Layer,HAL)的价值不是“给 SDK 再套一层接口”,而是建立一套由业务需要定义、语义稳定且可验证的设备能力模型。本文将用运动轴作为贯穿示例,说明能力接口、适配器和模拟器应该如何分工。半导体设备中运动控制与硬件抽象的领域实践见《半导体设备软件(四):运动控制与硬件抽象》。
1. 抽象能力,不抽象厂商函数
假设厂商 SDK 提供以下函数:
1
2
3
4
5
| OpenCard(cardNo)
SetProfile(axisNo, velocity, acceleration)
MoveAbs(axisNo, pulse)
GetMotionStatus(axisNo)
GetLastError()
|
机械地把每个函数改成 C# 方法,只是把调用位置搬了家。上层仍然要知道脉冲换算、状态位、到位条件和错误码,并不能替换实现。
能力接口应该从调用者真正需要的语义出发。例如流程需要“以指定速度移动到某个物理位置,并在到位或失败后结束”:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| public readonly record struct Millimeters(double Value);
public readonly record struct MillimetersPerSecond(double Value);
public enum AxisStopMode
{
Decelerated,
Immediate
}
public sealed record AxisSnapshot(
Millimeters ActualPosition,
bool IsMoving,
bool IsServoOn,
bool PositiveLimit,
bool NegativeLimit,
string? FaultCode);
public interface IAxis
{
ValueTask<AxisSnapshot> ReadAsync(CancellationToken cancellationToken);
ValueTask MoveAbsoluteAsync(
Millimeters target,
MillimetersPerSecond speed,
CancellationToken cancellationToken);
ValueTask StopAsync(
AxisStopMode mode,
CancellationToken cancellationToken);
}
|
这里没有暴露卡号、脉冲数或厂商状态字。强类型单位让位置和速度不容易被交换,也迫使适配器集中处理脉冲当量、零点和符号方向。接口是否使用 ValueTask 不是架构重点;若实现通常异步完成,使用 Task 同样合理,不应为了微小分配收益增加调用复杂度。
2. 把语义翻译集中在适配器
适配器负责把稳定能力映射到具体 SDK,它至少需要处理五件事:
- 生命周期:打开、初始化、关闭句柄;
- 数据映射:单位、坐标、枚举和位域;
- 完成语义:区分“命令已接受”和“物理动作已完成”;
- 错误映射:保留原始错误码,同时提供稳定的领域分类;
- 并发约束:遵守 SDK 对调用线程和重入的要求。
简化后的轮询等待可以写成:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
| public sealed class VendorAxisAdapter(
IVendorMotionApi api,
int axisNumber,
double pulsesPerMillimeter,
TimeProvider timeProvider) : IAxis
{
public async ValueTask MoveAbsoluteAsync(
Millimeters target,
MillimetersPerSecond speed,
CancellationToken cancellationToken)
{
int targetPulse = checked((int)Math.Round(
target.Value * pulsesPerMillimeter,
MidpointRounding.AwayFromZero));
// 速度同样按脉冲当量换算为 pulse/s,与目标位置保持同一单位域。
api.StartAbsoluteMove(
axisNumber,
targetPulse,
speed.Value * pulsesPerMillimeter);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
VendorAxisState state = api.ReadState(axisNumber);
if (state.FaultCode is not null)
{
throw new DeviceFaultException(
"AxisMotionFailed",
state.FaultCode);
}
if (!state.IsMoving && state.InPosition)
{
return;
}
await Task.Delay(
TimeSpan.FromMilliseconds(20),
timeProvider,
cancellationToken);
}
}
public ValueTask<AxisSnapshot> ReadAsync(
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
VendorAxisState state = api.ReadState(axisNumber);
return ValueTask.FromResult(new AxisSnapshot(
new Millimeters(state.ActualPulse / pulsesPerMillimeter),
state.IsMoving,
state.IsServoOn,
state.PositiveLimit,
state.NegativeLimit,
state.FaultCode));
}
public ValueTask StopAsync(
AxisStopMode mode,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
api.Stop(axisNumber, immediate: mode is AxisStopMode.Immediate);
return ValueTask.CompletedTask;
}
}
|
其中 IVendorMotionApi 和 VendorAxisState 是对原始 SDK 的窄封装:前者负责调用厂商函数,后者把一次读取所需的状态位和实际脉冲数打包返回。它们没有在示例中展开,因为具体签名必须以所用 SDK 版本为准。
这段代码只是说明职责位置,不代表所有轴都应以 20 ms 轮询。支持事件回调或硬件完成通知时,应按厂商文档选择机制;轮询周期也要结合控制器负载和业务响应要求测量。取消等待只表示调用方不再等待,适配器还必须按照设备策略决定是否发减速停止,不能假定取消 Task 会让物理轴自动停下。
3. 错误需要两层信息
只抛出 Exception("移动失败") 会丢失现场证据;让流程判断 -2017 又会把厂商实现泄漏出去。一个可维护的错误模型通常同时保留:
- 稳定分类:超时、通信中断、联锁不满足、设备报警、参数非法;
- 原始证据:厂商错误码、命令、控制器状态字和发生时间。
1
2
3
4
5
6
7
8
| public sealed class DeviceFaultException(
string faultKind,
string vendorCode)
: Exception($"Device operation failed: {faultKind}")
{
public string FaultKind { get; } = faultKind;
public string VendorCode { get; } = vendorCode;
}
|
预期内且高频出现的业务拒绝,例如“门未关闭,不能启动”,可以返回显式结果;真正无法继续的驱动故障更适合异常。关键不是统一用返回值还是异常,而是调用契约必须说明哪些结果可恢复、物理动作是否已经开始,以及失败后设备可能处于什么状态。
4. 资源所有权必须唯一
多数设备 SDK 背后持有非托管句柄、回调或驱动缓冲区。创建适配器的一方应该明确拥有并释放这些资源:
- 原生句柄优先封装为
SafeHandle,避免在普通业务类里手写终结器; - 异步关闭需要等待时,实现
IAsyncDisposable; - 不要让多个适配器分别关闭同一个全局 SDK;
- 回调注册和注销使用同一个委托实例,并在关闭前停止产生新回调;
- 关闭过程设计为幂等,允许在部分初始化失败后再次执行。
.NET 的垃圾回收器只管理托管内存,不会自动理解厂商句柄代表的硬件资源。Dispose 也不是“将来有空再清理”的提示,而是资源所有权协议的一部分。
5. 线程安全不能靠猜
厂商 SDK 是否线程安全,应以对应版本文档和实测为准。文档没有明确保证时,保守做法是让一个所有者串行化对设备的写操作,而不是在 UI、流程和监控线程中同时调用 SDK。
读状态也不一定可以任意并发:有的 SDK 使用进程级“最后错误码”,有的回调只能在初始化线程处理,还有的底层总线本来就是半双工。适配层应隐藏这些限制,并向上层提供一致的异步契约。若必须切换到专用线程,应把线程调度封装在适配器内部,而不是要求每个调用方记住 Invoke。
6. 模拟器要实现同一份契约
模拟器不是返回固定成功值的 Mock。它至少要保留真实接口的重要行为:
- 操作需要时间,状态会经历变化;
- 非法状态下拒绝命令;
- 支持取消、超时和故障注入;
- 单位、范围与软限位规则一致;
- 能构造断连、卡住和部分完成等失败场景。
由于流程只依赖 IAxis,组合根可以决定使用真实适配器还是模拟器:
1
2
3
4
5
6
7
8
9
| // 需要 using Microsoft.Extensions.Configuration;(Worker 项目模板的隐式 using 已包含它)。
if (builder.Configuration.GetValue<bool>("Equipment:Simulation"))
{
builder.Services.AddSingleton<IAxis, SimulatedAxis>();
}
else
{
builder.Services.AddSingleton<IAxis, VendorAxisAdapter>();
}
|
不要在业务代码里到处判断 if (isSimulation)。真实与仿真实现的差异应留在组合根和适配层,否则两条逻辑路径会逐渐漂移。
7. 评审一份 HAL 的问题清单
在接口稳定之前,可以逐项检查:
- 方法描述的是业务能力,还是照搬厂商函数名?
- 单位、坐标系、精度和范围是否明确?
- 返回完成时,物理设备究竟到达了什么状态?
- 取消或超时后,动作仍可能继续吗?
- 原始错误证据是否保留,同时避免流程依赖厂商码?
- 谁拥有句柄、回调和关闭责任?
- 并发调用是否有文档依据,还是仅仅“目前没出过问题”?
- 同一套契约能否由模拟器实现并用于测试?
一个接口越短,不一定越好;一个接口越通用,也不一定越抽象。好的 HAL 会准确表达本设备需要保证的能力,并主动拒绝无法诚实实现的“万能接口”。
总结
硬件抽象层是语义边界:上层说物理能力,适配器处理厂商细节。单位换算、完成条件、错误映射、线程限制和资源所有权都应在这条边界上得到统一解释。这样做不仅为了将来换硬件,更为了让今天的流程能够被模拟、测试和诊断。
下一篇将继续讨论设备从离线到可运行的生命周期,解决初始化一半失败、重复连接、退出清理和重启后状态重建等问题。
参考资料