记一次 .NET P/Invoke 引发的周期性段错误:libiec61850 GetFile 的 GC 陷阱
运行稳定的网关,每隔大约 30 分钟必然崩溃一次。日志里只有一行
Segmentation fault (core dumped),没有托管异常,没有堆栈,内存无溢出、cpu负载正常。
一、背景
项目是一套辅控新系统,通过 libiec61850 与 IED 设备通信。通信层核心是 libiec61850 的 .NET 绑定(IEC61850forCSharp),功能包括:
- 定时轮询(每 60s)通过 MMS 文件服务从设备拉取 XML 状态文件
- 订阅 RCB 报告,设备有变化时主动推送文件路径,触发即时下载
上线后发现网关进程约每 30 分钟崩溃一次,自动重启后运行正常,周而复始。
二、排查过程
2.1 缩小范围
首先排除业务代码问题:把轮询和报告处理全部关掉,网关稳定运行了数小时,没有崩溃。逐步恢复功能后,确认问题出在文件拉取这条路径上,即 IedConnection.GetFile 调用链。
日志里能看到崩溃前的最后一条记录通常是某个文件正在下载中,之后进程直接消失,没有任何托管异常被捕获。
2.2 关键线索:托管异常 vs 段错误
.NET 的 try/catch 可以捕获绝大多数运行时错误,但段错误直接终止进程,完全绕过托管异常机制。这说明崩溃发生在 native 代码里,或者是 native 代码通过一个非法指针跳转到了不可执行的内存区域。
结合调用链指向 IedConnection_getFile(native P/Invoke),自然把怀疑指向了 managed 和 native 之间的边界。
2.3 翻 libiec61850 源码
打开 libiec61850 的 mms_client_connection.c,找到 IedConnection_getFile 的实现,发现了一个关键细节——它不是真正意义上的同步阻塞调用。
三、libiec61850 的线程模型
理解崩溃根因之前,必须先搞清楚 libiec61850 内部是怎么跑的。
3.1 后台线程
IedConnection_create(),native 库内部会创建一个后台线程:
static void* connectionHandlingThread(void* parameter)
{
MmsConnection self = (MmsConnection) parameter;
while (self->connectionThreadRunning)
{
if (MmsConnection_tick(self))
Thread_sleep(10);
}
return NULL;
}
这个线程每 10ms 调用一次 MmsConnection_tick,负责处理所有从 TCP Socket 收到的数据,包括:
- MMS 响应 PDU(文件读取、数据读写等)
- InformationReport(RCB 报告推送)
所有回调都从这个后台线程发起。
3.2 "同步"文件读取的真相
IedConnection_getFile 看起来是一个同步阻塞函数,实际上内部是信号量等待 + 后台线程唤醒的模式:
// mms_client_connection.c — MmsConnection_fileRead 实现
bool MmsConnection_fileRead(MmsConnection self, MmsError* mmsError,
int32_t frsmId, MmsFileReadHandler handler, void* handlerParameter)
{
struct fileReadParameters parameter;
parameter.waitForResponse = Semaphore_create(1);
parameter.handler = handler;
parameter.handlerParameter = handlerParameter;
Semaphore_wait(parameter.waitForResponse);
MmsConnection_fileReadAsync(self, NULL, &err,
frsmId, fileReadHandler, ¶meter);
Semaphore_wait(parameter.waitForResponse);
Semaphore_destroy(parameter.waitForResponse);
return parameter.moreFollows;
}
后台线程收到响应后执行:
static void fileReadHandler(...) {
parameters->handler(parameters->handlerParameter, frsmId, buffer, byteReceived);
Semaphore_post(parameters->waitForResponse);
}
结论:C# 回调 iedClientGetFileHandler 是由 native 后台线程调用的,而非发起 GetFile 请求的那个 managed 线程。
四、根因:Delegate 被 GC 回收
4.1 问题代码
// IEC61850ClientAPI.cs(libiec61850 官方 .NET 绑定)
public void GetFile(string fileName, GetFileHandler handler, object parameter)
{
int error;
GetFileCallback getFileCallback = new GetFileCallback(handler, parameter);
GCHandle handle = GCHandle.Alloc(getFileCallback);
IedConnection_getFile(connection, out error, fileName,
new InternalIedClientGetFileHandler(iedClientGetFileHandler), // ← 问题所在
GCHandle.ToIntPtr(handle));
if (error != 0)
throw new IedConnectionException("Error reading file", error);
handle.Free();
}
new InternalIedClientGetFileHandler(iedClientGetFileHandler) 创建了一个临时 delegate 对象,直接作为参数传给 P/Invoke,在 managed 侧没有任何变量引用它。
4.2 P/Invoke 如何传递 Delegate
理解 GC 为什么会回收这个 delegate,需要先了解 .NET 如何把 delegate 传给 native 代码。
当你把一个 delegate 传给 P/Invoke 时,运行时会为它生成一个 native callable thunk(原生可调用的跳板代码),这个 thunk 的地址作为函数指针传给 native:
managed delegate 对象 → native thunk(一段跳板代码) → 函数指针(整数)
↑ ↑
GC 管理的对象 由 delegate 对象持有
native 代码拿到的只是一个整数(函数指针),GC 完全不知道 native 侧存了这个地址。
五、次要问题:Marshal.Copy 的 Null 指针风险
回调函数里还有另一个潜在崩溃点:
private bool iedClientGetFileHandler(IntPtr parameter, IntPtr buffer, UInt32 bytesRead)
{
GCHandle handle = GCHandle.FromIntPtr(parameter);
GetFileCallback getFileCallback = (GetFileCallback)handle.Target;
byte[] bytes = new byte[bytesRead];
Marshal.Copy(buffer, bytes, 0, (int)bytesRead); // ← buffer 可能是 IntPtr.Zero
return getFileCallback.handler(getFileCallback.parameter, bytes);
}
native 层 mmsFileReadHandler 对 buffer 没有 null 检查:
static void mmsFileReadHandler(void* parameter, int32_t frsmId,
uint8_t* buffer, uint32_t bytesReceived)
{
handler->retVal = handler->handler(handler->handlerParameter,
buffer, bytesReceived); // buffer 可能是 NULL
}
当设备发送空文件块时,buffer = NULL,bytesReceived = 0。
Marshal.Copy(IntPtr.Zero, bytes, 0, 0) 的行为:
| 平台 | 结果 |
|---|---|
| Windows x64 | 通常不崩溃(length=0 时 memcpy 是 no-op) |
| Linux ARM64 | 部分 libc 实现会解引用源地址 → SIGSEGV |
六、修复
6.1 主要修复:GC.KeepAlive
public void GetFile(string fileName, GetFileHandler handler, object parameter)
{
int error;
GetFileCallback getFileCallback = new GetFileCallback(handler, parameter);
GCHandle handle = GCHandle.Alloc(getFileCallback);
// 存入命名变量,确保 managed 侧有引用
var getFileDelegate = new InternalIedClientGetFileHandler(iedClientGetFileHandler);
IedConnection_getFile(connection, out error, fileName, getFileDelegate,
GCHandle.ToIntPtr(handle));
// 告知 GC:在这行之前不允许回收 getFileDelegate
GC.KeepAlive(getFileDelegate);
if (error != 0)
throw new IedConnectionException("Error reading file", error);
handle.Free();
}
GC.KeepAlive(x) 本身不做任何运行时操作,零开销。它的作用是在编译层面生成一个对 x 的人工引用点,阻止 JIT 把 x 的生命周期提前结束。
6.2 次要修复:Null 检查
private bool iedClientGetFileHandler(IntPtr parameter, IntPtr buffer, UInt32 bytesRead)
{
GCHandle handle = GCHandle.FromIntPtr(parameter);
GetFileCallback getFileCallback = (GetFileCallback)handle.Target;
if (bytesRead == 0 || buffer == IntPtr.Zero)
return getFileCallback.handler(getFileCallback.parameter, Array.Empty<byte>());
byte[] bytes = new byte[bytesRead];
Marshal.Copy(buffer, bytes, 0, (int)bytesRead);
return getFileCallback.handler(getFileCallback.parameter, bytes);
}
文笔AI润色

浙公网安备 33010602011771号