记一次 .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, &parameter);

    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 层 mmsFileReadHandlerbuffer 没有 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 = NULLbytesReceived = 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润色

posted @ 2026-04-15 14:11  daibitx  阅读(42)  评论(0)    收藏  举报