设备 SDK 常通过回调上报图像到达、运动完成、报警和连接变化。注册成功只完成了第一步:原生库可能在任意线程、任意时刻调用函数指针,并继续使用注册时传入的上下文。委托被 GC 回收、设备句柄已关闭、缓冲区已失效或异常穿过 ABI 边界,都可能导致进程级崩溃。
本文建立回调的完整生命周期,并用非托管函数指针(C# 9/.NET 5 起可用)在 .NET 10 环境下演示一条可验证的实现路径。
1. 先读清原生回调契约
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| #include <stddef.h>
#include <stdint.h>
typedef void (*device_event_callback)(
void* context,
int32_t event_code,
const uint8_t* payload,
size_t payload_length);
int32_t device_register_callback(
device_handle handle,
device_event_callback callback,
void* context);
int32_t device_unregister_callback(device_handle handle);
|
头文件之外还要确认:
- 回调使用哪种调用约定;
- SDK 是同步回调还是由内部线程异步回调;
- 是否可能并发、重入或在注销期间继续进入;
payload 只在本次回调有效,还是可由调用者释放;- 注销返回时是否保证所有在途回调结束;
- 关闭设备句柄前必须执行什么顺序。
2. 使用函数指针表达精确 ABI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
internal static partial class DeviceNative
{
[LibraryImport("device_sdk", EntryPoint = "device_register_callback")]
internal static unsafe partial int RegisterCallback(
nint handle,
delegate* unmanaged[Cdecl]<nint, int, byte*, nuint, void> callback,
nint context);
[LibraryImport("device_sdk", EntryPoint = "device_unregister_callback")]
internal static partial int UnregisterCallback(nint handle);
}
|
delegate* unmanaged[Cdecl]<...> 表达的是非托管函数指针,参数顺序与原生 typedef 一一对应。若 SDK 不是 cdecl,两侧必须同时改为真实调用约定。
3. 用 UnmanagedCallersOnly 暴露静态入口
实例方法带有隐含的 this,不能直接作为普通 C 回调。可以暴露一个静态入口,把实例放在 context 中:
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
70
71
72
73
74
75
76
77
78
79
80
| using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.IO;
using System.Threading.Channels;
internal static class DeviceCallbackBridge
{
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static unsafe void OnEvent(
nint context,
int eventCode,
byte* payload,
nuint payloadLength)
{
try
{
var handle = GCHandle.FromIntPtr(context);
if (handle.Target is not DeviceEventSink sink)
{
return;
}
if (payloadLength > sink.MaxPayloadLength ||
(payload == null && payloadLength != 0))
{
CallbackFailureLog.Write(
new InvalidDataException("Invalid callback payload"));
return;
}
int length = checked((int)payloadLength);
byte[] copy = new ReadOnlySpan<byte>(payload, length).ToArray();
sink.TryPublish(new NativeDeviceEvent(eventCode, copy));
}
catch (Exception exception)
{
CallbackFailureLog.Write(exception);
}
}
}
public sealed record NativeDeviceEvent(int Code, byte[] Payload);
public sealed class DeviceEventSink
{
private readonly Channel<NativeDeviceEvent> _events =
Channel.CreateBounded<NativeDeviceEvent>(64);
public DeviceEventSink(nuint maxPayloadLength)
{
if (maxPayloadLength == 0 || maxPayloadLength > int.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(maxPayloadLength));
}
MaxPayloadLength = maxPayloadLength;
}
public nuint MaxPayloadLength { get; }
public ChannelReader<NativeDeviceEvent> Events => _events.Reader;
public bool TryPublish(NativeDeviceEvent deviceEvent) =>
_events.Writer.TryWrite(deviceEvent);
}
internal static class CallbackFailureLog
{
public static void Write(Exception exception)
{
try
{
Console.Error.WriteLine(exception);
}
catch
{
// 回调边界不能因诊断失败再次抛出。
}
}
}
|
这里使用有界通道,队列已满时 TryWrite 会失败;生产代码应记录丢弃计数或根据事件类型采用受控降载策略,不能静默假装事件已经处理。
UnmanagedCallersOnly 方法必须是静态方法,签名只能使用本系列(三)定义的 blittable 类型。异常绝不能穿过原生边界;回调入口要捕获全部异常,并把故障写入不再抛出的最小日志通道。
示例立即复制 payload,因为没有契约允许回调返回后继续读取该指针。最大长度由创建 DeviceEventSink 时结合设备数据模型配置,避免损坏的 SDK 值触发超大分配,又不武断限制合法图像或波形大小。
4. 上下文句柄必须活到最后一次回调结束
注册时用普通 GCHandle 保持托管对象可达。这里固定的是对象的生命周期,不是内存地址;默认 GCHandleType.Normal 仍允许 GC 移动对象:
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
| public sealed unsafe class CallbackRegistration : IDisposable
{
private readonly nint _device;
private GCHandle _context;
private bool _registered;
public CallbackRegistration(nint device, DeviceEventSink sink)
{
_device = device;
_context = GCHandle.Alloc(sink);
int status = DeviceNative.RegisterCallback(
device,
&DeviceCallbackBridge.OnEvent,
GCHandle.ToIntPtr(_context));
if (status != 0)
{
_context.Free();
throw new DeviceSdkException("device_register_callback", status);
}
_registered = true;
}
public void Dispose()
{
if (!_registered)
{
return;
}
int status = DeviceNative.UnregisterCallback(_device);
if (status != 0)
{
throw new DeviceSdkException("device_unregister_callback", status);
}
// 只有 SDK 保证注销返回后无在途回调,才能在这里释放。
_context.Free();
_registered = false;
}
}
|
如果注销不等待在途回调,这个简化版本不安全。需要增加计数/屏障,或调用 SDK 提供的同步停止函数,按“停止产生新回调 → 等待已有回调退出 → 释放 context → 关闭设备”的顺序执行。
异常路径也要设计:如果注销失败,立即释放 GCHandle 可能形成悬空上下文;无限保留则泄漏。应根据厂商契约决定重试、强制停止或把会话转入不可恢复状态,而不是在 finally 中盲目释放。
5. 回调线程不等于 UI 线程
相机或运动控制 SDK 常从内部原生线程调用。回调中应只做有界、非阻塞工作:
- 复制必要数据和时间戳;
- 写入
Channel<T>、无界风险受控的队列或专用调度器; - 立即返回,让 SDK 线程继续工作;
- 在消费者侧解析、记录、更新状态并切换到 UI Dispatcher。
不要在回调里等待 UI、获取长时间持有的业务锁、同步调用关闭设备,或执行不可控的磁盘/网络 I/O。否则不仅丢帧,还可能与 SDK 内部锁形成死锁。锁与死锁的通用机制见《线程安全的本质:从 CPU 缓存到内存模型,锁到底在保证什么》。
6. 委托回调仍然可用,但必须扎根
旧 API 或不适合函数指针的场景可以声明委托:
1
2
3
4
5
6
| [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal unsafe delegate void DeviceEventCallback(
nint context,
int eventCode,
byte* payload,
nuint payloadLength);
|
如果原生代码只在一次同步调用期间使用委托,可在调用后用 GC.KeepAlive(callback) 保证其活到调用结束。如果原生库保存函数指针,就必须把委托存入与注册同寿命的字段,不能只依赖局部变量、GC.KeepAlive 或“目前 GC 还没发生”。
7. 回调测试要覆盖时间窗口
至少验证:
- 注册失败时 context 不泄漏;
- 回调在工作线程、并发和重入时仍正确;
- 空载荷、最大载荷和非法长度受到保护;
- 回调处理异常不会越过 ABI;
- 注销与回调竞争时没有 use-after-free;
- 关闭设备后不会再向已释放队列或 UI 发布;
- 多次启动/停止不会重复注册。
模拟原生线程的测试应主动随机化回调时序,而不是只在单线程中顺序调用入口。
总结
回调是一段跨线程、跨 GC、跨 ABI 的长期租约。函数签名正确只是起点;托管目标、委托或 GCHandle 必须保持到最后一次回调结束,载荷要在有效期内复制,异常不能穿过边界,耗时工作应转移到托管队列。
下一篇用 SafeHandle 管理设备句柄,让关闭时机和调用期间的存活保证进入类型系统。
参考资料