【C# Interop】Marshal 的魅力 - 定义结构体的另类方法

本文最后更新于:几秒前

前言

关于 Marshal,中文名 封送,指 .NET 运行时 (CLR) 与非托管代码 (Managed <-> Unmanaged) 交互 (Interoperability) 时双向传输对象的机制。

封送主要有自动和手动两种。如果我们直接在导入函数签名或结构体等中声明 Blittable 类型,在互操作时 CLR 会自动处理;相对的,如果我们声明为 IntPtr,特别是涉及到指针的,此时就需要我们调用 System.Runtime.InteropServices.Marshal 这个静态类中一系列的方法来手动管理内存分配以及封送。

在 Interop 中,我们通常会定义一大堆结构体,这其实是可以理解的。但是,如果我们遇到了以下特殊场景,就得用特殊手段了:(点击链接可以跳转到相应的部分)

空结构体的神奇用法

场景:使用 WinForms 自带的方法将 LOGFONT 转为 Font。(在 自定义 Font/ColorDialog 等常用对话框样式 中提到过)
示例代码链接

这是 LOGFONT 的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct LOGFONT
{
public int lfHeight;
public int lfWidth;
public int lfEscapement;
public int lfOrientation;
public int lfWeight;
public byte lfItalic;
public byte lfUnderline;
public byte lfStrikeOut;
public byte lfCharSet;
public byte lfOutPrecision;
public byte lfClipPrecision;
public byte lfQuality;
public byte lfPitchAndFamily;

[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string lfFaceName;
}

如果你需要把它转换为 Font 类型使用,以及将 Font 转换为 LOGFONT,可以用以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
public readonly Font ToFont()
{
using var tmp = Font.FromLogFont(this);
return new(tmp.FontFamily, tmp.SizeInPoints, tmp.Style, GraphicsUnit.Point, tmp.GdiCharSet, tmp.GdiVerticalFont);
}

public static LOGFONT FromFont(Font font)
{
object lfobj = new LOGFONT();
font.ToLogFont(lfobj);
return (LOGFONT)lfobj;
}

可以看到,上述两个方法都没有用到 LOGFONT 的任何一个字段就完成了与 Font 的相互转换,那我们是否可以将字段全部省略呢?

但是省略之后,结构体就空了,也就是没有大小了,传入导入函数必定会出错。

如果你在写 StructLayout 特性时好奇过我们几乎不会用到的 Size 属性:

想必你已经突然意识到这个 Size 是干什么的了,也就是指定该结构的绝对大小,不受操作系统、字符集等的影响。有了这个,CLR 就会用我们定义的大小,而不是帮我们计算。

也就是说我们删除 LOGFONT 所有字段后再显式指定 Size 属性就行,只要大小对的上,传入到函数就基本不会有问题。

那 LOGFONT 的大小怎么获取呢?我们可以调用一下 Marshal.SizeOf 方法 (先别删除所有字段):

当然如果你也可以手动计算:5个int+8个byte+32个宽字符=4x5+1x8+2x32=92

除此之外如果你的 Visual Studio 安装了 C++ 以及 Windows SDK,你可以直接打开 LOGFONT 结构体所在头文件,并将光标悬停在类型名上,你就会发现:

于是我们就知道了 LOGFONT 的 Size 是 92,保险起见我们还是再指定字符集为 Unicode。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode, Size = 92)]
public struct LOGFONT
{
public readonly Font ToFont()
{
using var tmp = Font.FromLogFont(this);
return new(tmp.FontFamily, tmp.SizeInPoints, tmp.Style, GraphicsUnit.Point, tmp.GdiCharSet, tmp.GdiVerticalFont);
}

public static LOGFONT FromFont(Font font)
{
object lfobj = default(LOGFONT);
font.ToLogFont(lfobj);
return (LOGFONT)lfobj;
}
}

是不是感觉非常简洁?

不定义直接访问字段

场景:使用 NMHDR 和 NMCUSTOMDRAW 控制 ListView 自绘行为 (在 从零开始让你的 WinForms 应用程序也用上原生深色主题 中提到过)
示例代码链接

这是 NMHDR 和 NMCUSTOMDRAW 的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[StructLayout(LayoutKind.Sequential)]
public struct NMHDR
{
public IntPtr hWndFrom;
public IntPtr idFrom;
public int code;
}

[StructLayout(LayoutKind.Sequential)]
public struct NMCUSTOMDRAW
{
public NMHDR hdr;
public int dwDrawStage;
public IntPtr hdc;
public RECT rc;
public IntPtr dwItemSpec;
public uint uItemState;
public IntPtr lItemlParam;
}

并通过以下代码来控制 ListView 自绘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected override void WndProc(ref Message m)
{
if (m.Msg == WM_NOTIFY &&
Marshal.PtrToStructure<NMHDR>(m.LParam).code == NM_CUSTOMDRAW)
{
var nmcd = Marshal.PtrToStructure<NMCUSTOMDRAW>(m.LParam);

switch (nmcd.dwDrawStage)
{
case CDDS_PREPAINT:
m.Result = new(CDRF_NOTIFYITEMDRAW);
return;
case CDDS_ITEMPREPAINT:
SetTextColor(nmcd.hdc, Color.White);
m.Result = new(CDRF_DODEFAULT);
return;
}
}

base.WndProc(ref m);
}

可以看到我们定义的结构体字段还是比较多的,且只从非托管拷贝,读取其中字段,不需要我们创建实例。因此就显得不必大费周章地去定义结构体。

那有没有办法能够不定义,就能读取到字段呢?还真有。

如果我们知道了指向该结构体的指针 (m.LParam),并知道想要的字段在哪个地方 (偏移),以及字段的类型 (决定了要读多少内存),那么是不是就可以拿到字段的值了?

我们要获取的字段及类型有

  • int NMHDR.code
  • int NMCUSTOMDRAW.dwDrawStage
  • IntPtr NMCUSTOMDRAW.hdc

在 Marshal 类中,有这样的一些方法:(未列举未在本场景使用的方法)

1
2
3
public unsafe static int ReadInt32(IntPtr ptr, int ofs);

public static IntPtr ReadIntPtr(IntPtr ptr, int ofs);

这些方法允许我们传入指针,以及偏移,就能拿到相应的对象。这不就是我们想要的吗?

这里又有一个新问题,我们应该怎么样才能得到字段的偏移?

不用担心,Marshal 也有对应的方法 OffsetOf,以 NMHDR.code 为例:

同样的,像前面看 LOGFONT 的大小一样,Visual Studio 也能直接看到结构体的各字段偏移。我们还是先把光标悬停在 NMHDR 的类型名上,点击内存布局 (Memory Layout),会弹出一个标签页,如下:

这样一来,我们就得到了所需字段的偏移,于是我们现在就可以删除那两个结构体的定义了,完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected override void WndProc(ref Message m)
{
if (m.Msg == WM_NOTIFY &&
Marshal.ReadInt32(m.LParam, 16) == NM_CUSTOMDRAW)
{
switch (Marshal.ReadInt32(m.LParam, 24))
{
case CDDS_PREPAINT:
m.Result = new(CDRF_NOTIFYITEMDRAW);
return;
case CDDS_ITEMPREPAINT:
SetTextColor(Marshal.ReadIntPtr(m.LParam, 32), Color.White);
m.Result = new(CDRF_DODEFAULT);
return;
}
}

base.WndProc(ref m);
}

总结

  • 省略结构体字段时注意在 StructLayout 上指定 Size 属性,确保封送后内存大小与 API 定义一致,其值必须是字段完整前提下获取到的。
  • 使用 Marshal.Read* 系列方法可以不用定义结构体类型就能拿到其中的字段,注意非托管类型与托管类型的映射。

【C# Interop】Marshal 的魅力 - 定义结构体的另类方法
https://wanghaonie.github.io/posts/66c8d20f6623/
作者
WangHaonie
发布于
2025-10-08 10:32:35
更新于
2025-10-08 23:00:34
许可协议