P/Invoke 错误还是我做错了?

P/Invoke bug or am I doing it wrong?

所以,我写了下面的代码:

using (var serviceController = new ServiceController(serviceName))
{
    var serviceHandle = serviceController.ServiceHandle;

    using (failureActionsStructure.Lock())
    {
        success = NativeMethods.ChangeServiceConfig2W(
            serviceHandle,
            ServiceConfigType.SERVICE_CONFIG_FAILURE_ACTIONS,
            ref failureActionsStructure);

        if (!success)
            throw new Win32Exception();
    }
}

P/Invoke声明如下:

[DllImport("advapi32", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool ChangeServiceConfig2W(IntPtr hService, ServiceConfigType dwInfoLevel, ref SERVICE_FAILURE_ACTIONSW lpInfo);

ServiceConfigType 只是一个 enum,这个特定成员的值为 2。SERVICE_FAILURE_ACTIONSW 结构定义如下:

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct SERVICE_FAILURE_ACTIONSW
{
    public int dwResetPeriod;
    public string lpRebootMsg;
    public string lpCommand;
    public int cActions;
    public IntPtr lpsaActionsPtr;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 1)]
    public SC_ACTION[] lpsaActions;

    class DataLock : IDisposable
    {
        IntPtr _buffer;

        public DataLock(ref SERVICE_FAILURE_ACTIONSW data)
        {
            int actionStructureSize = Marshal.SizeOf(typeof(SC_ACTION));

            // Allocate a buffer with a bit of extra space at the end, so that if the first byte isn't aligned to a 64-bit
            // boundary, we can simply ignore the first few bytes and find the next 64-bit boundary.
            _buffer = Marshal.AllocHGlobal(data.lpsaActions.Length * actionStructureSize + 8);

            data.lpsaActionsPtr = _buffer;

            // Round up to the next multiple of 8 to get a 64-bit-aligned pointer.
            if ((data.lpsaActionsPtr.ToInt64() & 7) != 0)
            {
                data.lpsaActionsPtr += 8;
                data.lpsaActionsPtr -= (int)((long)data.lpsaActionsPtr & ~7);
            }

            // Copy the data from lpsaActions into the buffer.
            IntPtr elementPtr = data.lpsaActionsPtr;

            for (int i=0; i < data.lpsaActions.Length; i++, elementPtr += actionStructureSize)
                Marshal.StructureToPtr(data.lpsaActions[i], elementPtr, fDeleteOld: false);
        }

        public void Dispose()
        {
            Marshal.FreeHGlobal(_buffer);
        }
    }

    internal IDisposable Lock()
    {
        return new DataLock(ref this);
    }
}

(这种类型在结构的末尾有一个额外的成员,它没有出现在本机结构定义中,lpsaActions,这简化了这种结构的使用,并且只会导致额外的数据被编组在最后——底层 API 简单地忽略的数据,因为它假设结构已经在内存中结束。)

SC_ACTION 定义如下:

[StructLayout(LayoutKind.Sequential)]
struct SC_ACTION
{
    public SC_ACTION_TYPE Type;
    public int Delay;
}

..而 SC_ACTION_TYPE 是一个简单的 enum:

enum SC_ACTION_TYPE
{
    SC_ACTION_NONE = 0,
    SC_ACTION_RESTART = 1,
    SC_ACTION_REBOOT = 2,
    SC_ACTION_RUN_COMMAND = 3,
}

我传入的结构是这样初始化的:

var failureActionsStructure =
    new SERVICE_FAILURE_ACTIONSW()
    {
        dwResetPeriod = 60000, // 60 seconds
        lpRebootMsg = "",
        lpCommand = "",
        cActions = 6,
        lpsaActions =
            new SC_ACTION[]
            {
                new SC_ACTION() { Type = SC_ACTION_TYPE.SC_ACTION_RESTART /* 1 */, Delay = 5000 /* 5 seconds */ },
                new SC_ACTION() { Type = SC_ACTION_TYPE.SC_ACTION_RESTART /* 1 */, Delay = 15000 /* 15 seconds */ },
                new SC_ACTION() { Type = SC_ACTION_TYPE.SC_ACTION_RESTART /* 1 */, Delay = 25000 /* 25 seconds */ },
                new SC_ACTION() { Type = SC_ACTION_TYPE.SC_ACTION_RESTART /* 1 */, Delay = 35000 /* 35 seconds */ },
                new SC_ACTION() { Type = SC_ACTION_TYPE.SC_ACTION_RESTART /* 1 */, Delay = 45000 /* 45 seconds */ },
                new SC_ACTION() { Type = SC_ACTION_TYPE.SC_ACTION_NONE /* 0 */, Delay = 0 /* immediate, and this last entry is then repeated indefinitely */ },
            },
    };

当我在 64 位进程中 运行 这段代码时,它工作得很好。但是,当我在 32 位进程中 运行 它时(实际上我只在 32 位 Windows 安装上测试了 32 位进程——我不确定在 32 位进程中会发生什么-位进程在 64 位 Windows 安装上),我总是得到 ERROR_INVALID_HANDLE 回来。

我做了一些挖掘。 ChangeServiceConfig2W API函数使用stdcall调用约定,也就是说当函数中的第一个操作码即将执行时,堆栈应该包括:

但是,当我将本机调试器附加到我的 32 位 C# 进程并在 ChangeServiceConfig2W 的第一条指令(从技术上讲是 _ChangeServiceConfig2WStub@12 的第一条指令)上放置断点时,我发现堆栈包括:

我用一个简单的 C++ 应用程序确认 [ESP] 的第二个和第三个 DWORD 应该 是服务句柄和常量值 2。我尝试了各种替代 P/Invoke 声明,包括对前两个参数使用 int 而不是 IntPtrServiceConfigType,但无法获得任何其他行为。

最后,我更改了第三个参数,ref SERVICE_FAILURE_ACTIONSW,改为直接使用 IntPtr,并使用 Marshal.StructureToPtr 手动将 failureActionsStruct 编组到分配的块中Marshal.AllocHGlobal。通过此声明,第一个和第二个参数现在可以正确编组。

所以,我的问题是,我最初声明 ChangeServiceConfig2W 函数的方式是否做错了,这可能导致前两个参数没有正确编组?可能性似乎很小,但我不能否认我 运行 正在进入 P/Invoke(特别是封送拆收器)中的实际错误的可能性。

奇怪的是,DWORD0x0000AFC8 是我传入的 failureActionsStructureSC_ACTION 结构之一的 45,000 毫秒 Delay。但是,它是该实例的最后一个成员,并且堆栈中 0x0000AFC8 之后的 0x00000001 不会是随后 SC_ACTIONType。即使是这样,我也看不出是什么导致这些值专门写入 P/Invoke 调用的参数区域。如果它将整个结构序列化到内存中的错误位置并覆盖堆栈的一部分,那不会导致内存损坏并可能终止进程吗?

我很困惑。 :-)

我相信我已经明白是怎么回事了。在我看来,它 P/Invoke 封送拆收器中的一个错误,但如果微软的官方说法是 "This behaviour is by design."

我也不会感到惊讶

当您将数组配置为在结构中封送时,您的选择非常受限。据我所知,您可以将 UnmanagedType.SafeArray 用于任何 VARIANT 原始类型,或者您可以使用 UnmanagedType.ByValArray,这需要您指定 SizeConst —— 即, 封送拆收器仅支持长度始终完全相同的嵌入式数组。

Marshal.SizeOf函数,那么,在计算结构体大小时,将数组的大小统计为SizeConst * Marshal.SizeOf(arrayElementType)。无论实例指向的数组的实际大小如何,情况总是如此。

错误似乎是封送拆收器总是复制数组中的所有元素,即使该数字大于 SizeConst。因此,在我的情况下,如果您将 SizeConst 设置为 1,但提供一个包含 6 个元素的数组,那么它会根据 Marshal.SizeOf 分配内存,这会为数组数据分配一个插槽,然后继续编组所有 6 个元素,写入缓冲区末尾并破坏内存。

参数的堆栈槽以这种方式损坏的原因只能通过封送拆收器在堆栈上为此序列化分配内存来解释。通过溢出该缓冲区的末尾,它随后覆盖了堆栈中更远的数据,包括最终写入 return 地址的位置以及已经放入堆栈槽中的前两个参数。在此封送处理操作之后,它将指向该堆栈缓冲区的指针写入第三个参数,解释为什么第三个参数的值实际上是指向数据结构的有效指针。

我很幸运,在我的特定配置下,第 6 个元素的末尾发生在破坏堆栈中更远的其他元素之前,因为只有 ChangeServiceConfig2W 的前两个参数被破坏 -- 代码在 ChangeServiceConfig2W return 编辑了有关句柄无效的错误后能够继续执行。使用更大的数组,或者更简单的函数,其中封送拆收器分配的缓冲区更接近堆栈帧的末尾,它很可能已经覆盖了堆栈更远的重要数据并导致 ExecutionEngineException as @GSerg锯.

在 64 位系统上,堆栈上必须有更多空闲 space -- 一方面,所有指针现在都是 64 位宽,因此 space 可以解决问题。那么,写入缓冲区末尾后,不会到达堆栈的最深处,也不会设法破坏 ChangeServiceConfig2W 的第一个或第二个参数。这就是这段代码在初始测试中的工作方式,并且看起来是正确的。

在我看来,这是封送拆收器中的一个错误;它有足够的信息来避免破坏内存(只是不要编组超过 SizeConst 个元素,因为那是你分配的所有内存!),但它会继续并写入已分配缓冲区的末尾。我可以看到相反的哲学,即 "If you've told the marshaler SizeConst is 1, then don't supply an array with more than 1 element." 但是在我阅读的任何文档中都没有明确警告这样做会破坏执行环境。考虑到 .NET 竭尽全力避免这种类型的损坏,我不得不将其视为封送拆收器中的错误。

我已经通过更新 DataLock class 使我的代码正常工作,它暂时将 lpsaActions 数据准备为指向数组的指针(ChangeServiceConfig2W 需要并且默认的 P/Invoke 封送拆收器似乎不支持),以存储真正的 lpsaActions 数组并将其替换为虚拟的 1 元素数组。这可以防止编组器编组超过 1 个元素,并且不会发生内存损坏。当 DataLock 对象为 Disposed 时,它会将 lpsaActions 恢复为其先前的值。

我已将此(工作)代码放入 public GitHub 存储库,以及一个基本相同的 C++ 版本,用于在代码流进入时比较寄存器和堆栈的状态我诊断期间的 ChangeServiceConfig2W 功能:

要重现我看到的问题,请注释掉 WindowsAPI/SERVICE_FAILURE_ACTIONSW.cs 中的这些行:

            // Replace the lpsaActions array with a dummy that contains only one element, otherwise the P/Invoke marshaller
            // will allocate a buffer of size 1 and then write lpsaActions.Length items to it and corrupt memory.
            _originalActionsArray = data.lpsaActions;

            data.lpsaActions = new SC_ACTION[1];

然后,运行程序作为一个32位进程。 (如果您使用的是 64 位操作系统,您可能需要调整构建配置,以便它指定 "Prefer 32-bit" 或直接针对 "x86" 平台。)

Microsoft 可能会或可能不会意识到并修复此错误。至少,Microsoft 最好更新 UnmanagedType.ByValArray 的文档以包含有关这种可能情况的警告——我不确定如何将其传达给他们。不过,考虑到当前版本的 .NET 有它,我认为在将结构封送到非托管代码时最好避免提供长度不完全等于 SizeConst 的数组。 :-)