SendMessage 调不通,Codex 连上 TaoToken 后能查 DllImport 声明
2026/9/17 1:31:29 网站建设 项目流程

C# 里SendMessage返回 0 的时候,别急着怀疑消息号。TaoTokenhttps://taotoken.net/?utm_source=taotoken_aicg_blog_end 建一把 Key,再用 Codex 对着CLineAndThetaCopyDataStruct这些声明逐项核对——EntryPoint 拼写、CharSet、[StructLayout]byte[]MarshalAs,一次问清楚,比一轮轮改签名快得多。

这篇按排障来写。原始那篇讲的是在 C# 里using System.Runtime.InteropServices;,然后[DllImport("User32.dll", EntryPoint = "SendMessage")]SendMsg声明成private static extern int SendMsg(int hwnd, int msg, int wparms, ref CLineAndTheta lparm),配一个自定义消息WM_FIXDATA = 0x0A4AB,调用的样子是SendMsg(windowHandler, WM_FIXDATA, 0, ref testfix.Fix_LT)。后面还有一套CopyDataStructWM_COPYDATA = 0x04AA,以及FindWindow(null, "TestMegaugingLib")拿窗口句柄。这套写法能跑通的时候非常爽,跑不通的时候几乎不给你任何提示,返回值全是 0。

所以顺序上我先把「返回值 0」拆成三类症状,再挨个核对声明层、结构体层、句柄层,中间穿插怎么让 Codex 帮你对照原生声明。工具归工具,SendMessage的坑还是得自己一项项排。

1. SendMsg 调用没反应时,先核 EntryPoint 与 CharSet

1.1 三种症状,对应三个不同的出错层

最有价值的一步是把「没反应」分类,不然就变成盲改签名。第一类是直接抛EntryPointNotFoundException或者DllNotFoundException,这类最简单,基本就是 DLL 名或函数名没对上,属于声明层的错。第二类是调用成功、SendMsg正常返回,但目标窗口那边毫无变化,返回值是 0——这类最阴,通常不是函数没找到,而是参数内容不对,比如结构体错位、消息号两边不一致、或者消息被目标窗口直接忽略了。第三类是返回值直接是 0 而且hwnd本身就无效,这是句柄层的问题,FindWindow那一环就挂了。

原始代码里SendMsg的返回类型写的是inthwndwparms也都是int。在 32 位进程里这没毛病,但换成 x64 编译,HWNDIntPtrWPARAMUIntPtrLPARAMIntPtr,全是 8 字节,用int接会被截断。截断之后SendMessage收不到有效窗口句柄,它不会报错,它只会返回 0。这一条在 2024 年之后的机器上几乎是必修项。

1.2 SendMessageA、SendMessageW 与 ExactSpelling

User32.dll里实际导出的名字是SendMessageASendMessageW,并没有一个叫SendMessage的导出符号。C# 的DllImport在默认情况下ExactSpelling = false,运行时会根据CharSet自动补上AW后缀:不写CharSet时默认按 Ansi 处理,最终找的是SendMessageA;写CharSet = CharSet.Unicode时找的是SendMessageW

一旦你显式写了ExactSpelling = true,运行时就不再补后缀了,EntryPoint = "SendMessage"会原封不动地去找一个叫SendMessage的导出符号,然后抛EntryPointNotFoundException。这就是为什么同样的声明,别人的项目能跑,你的项目一拷贝就炸——属性组合不同。

SendMessage这种带字符串或结构体的消息,推荐把CharSet明确写出来,别依赖默认值。原始代码里的FindWindow(string lpClassName, string lpWindowName)同理,窗口标题是中文或者带宽字符的时候,走FindWindowA和走FindWindowW的结果可能完全不一样。

[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr SendMessage(IntPtr hWnd, uint msg, UIntPtr wParam, IntPtr lParam);

1.3 WM_FIXDATA = 0x0A4AB 是不是两边一致的自定义消息

0x0A4AB换算成十进制是 42155,已经远大于WM_USER(0x0400)。这类消息号在 Win32 语义里属于「窗口类自定义消息」,它的合法使用范围基本限制在同一个进程、同一套窗口类实现里。原始代码用它配合ref CLineAndTheta传结构体指针,说明发送端和接收端大概率是同一套程序的两个窗口,或者至少是共享同一块地址空间。

如果接收窗口在另一个进程里,0x0A4AB这个数字本身不会报错,但对面的窗口过程很可能没有处理它,于是SendMessage老老实实返回 0,你以为是发送失败,其实是对方根本没这个分支。跨进程的自定义消息要么两边约定同一个数字并各自实现,要么改用RegisterWindowMessage注册一个全局唯一的消息 ID。排查时先在接收端的窗口过程里下一个断点,或者加一行日志,确认消息到底有没有到,这一步比改签名有用得多。

2. 让 Codex 逐行比对 DllImport 声明:先建 Key 再填 base_url

2.1 打开落地页创建 API Key

声明层的核对其实是体力活:要对照原生侧的 C/C++ 头文件、对照WM_*常量、对照结构体字段顺序,一项项比。这种活交给 Codex 很合适,但前提是它得有一个稳定的模型通道。先打开 https://taotoken.net/?utm_source=taotoken_aicg_blog_end ,注册之后进控制台创建一把 API Key,Key 只显示一次,复制出来先存好。同一把 Key 后面还能在模型对话里做验证,不用重复申请。

模型 ID 别凭记忆写,直接以 TaoToken 模型广场 当时的列表为准,里面是哪个就填哪个。

2.2 ~/.codex/config.toml 里加一个自定义 provider

Codex 走的是自己的配置文件,跟 Claude Code 的环境变量是两套东西,别把ANTHROPIC_*那组变量往这里套。在~/.codex/config.toml里加一个 provider 段,base_urlhttps://taotoken.net/api,注意末尾不要加/v1,加了之后请求路径会变成/v1/responses之类的双层结构,直接 404。

model = "YOUR_MODEL_ID" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "chat"

Key 通过环境变量传进去,Windows 上用setx TAOTOKEN_API_KEY YOUR_API_KEY,macOS 或 Linux 上写进~/.zshrcexport TAOTOKEN_API_KEY=YOUR_API_KEY。改完重开一个终端,让 Codex 确认读到了新的 provider。

2.3 把声明、调用点、接收端结构体一起丢给 Codex

问法很重要。只贴一句「SendMessage 没反应」,模型只能给你泛泛的建议。有效的做法是把四样东西一起贴过去:DllImport声明、常量定义(WM_FIXDATAWM_COPYDATA)、托管侧结构体完整定义、以及真正的那一行调用。然后明确要求它做逐字段对照,输出一张表:字段名、托管类型、字节宽度、在结构体里的偏移、以及推测的原生对应类型。

再补一句限制条件:接收窗口在不在同一个进程、目标平台是 x86 还是 x64、窗口标题是不是每次启动都变。这三个信息会直接改变结论。最后一定要提醒 Codex,它只负责生成和解释代码,编译、运行、点按钮都得你在本机做,结果再贴回来。把它当成一个愿意陪你逐行看声明的人,而不是一个能远程操作的执行器。

3. CLineAndTheta 传参错位:PointF、double 与 StructLayout

3.1 Sequential 是默认值,但不等于布局天然正确

C# 的 struct 默认就是LayoutKind.Sequential,字段按声明顺序排,Pack默认取平台值(x64 下是 8)。看起来很省心,问题在于它只保证「托管侧按顺序排」,不保证「和原生侧排得一样」。

原始的CLineAndTheta是三个字段:PointF startPtPointF endPtdouble thetaPointF内部是两个float,占 8 字节,对齐要求 4;double对齐要求 8。按顺序排下来是 startPt 在偏移 0,endPt 在偏移 8,theta 在偏移 16,总共 24 字节,中间没有填充。这个布局和「两个 8 字节点 + 一个 8 字节角度」的原生写法刚好一致,所以能跑通。

3.2 注释掉 int x、int y 之后,整体偏移差了 8 字节

原始代码里CLineAndTheta开头有两行被注释掉的public int x;public int y;。这两行很可能是历史遗留,也可能正是某次排障时不小心注释掉的。如果原生结构体里确实保留了这两个字段,托管侧又漏了它们,那么后面所有字段的偏移全部往前挪 8 字节:原生把startPt放在偏移 8,托管侧却在偏移 0 读取,theta读到的实际是endPt的后半截。表现就是角度值离谱、线段画到屏幕外面去,但函数返回值依然是 0,看起来「调用成功」。

排查手段很直接:跑一行Marshal.SizeOf(typeof(CLineAndTheta)),和原生侧sizeof(CLineAndTheta)的打印值比。数字不一致,就说明布局对不上,一个字段一个字段往回找。

[StructLayout(LayoutKind.Sequential, Pack = 8)] public struct CLineAndTheta { public PointF startPt; public PointF endPt; public double theta; }

3.3 Pack 对不上时,用 Explicit 兜底

有一种情况比字段缺失更难查:原生侧编译时用了#pragma pack(4)或者/Zp4。这时候 double 的对齐要求被压到 4,结构体总长度可能变成 24 也可能变成 20,取决于字段排列。托管侧Pack不写,默认 8,两边算出来的偏移就不一样。

遇到这种,别猜。把托管结构体改成LayoutKind.Explicit,用FieldOffset把每个字段的偏移写死,偏移值从原生侧的offsetof打印里抄。这样两边不管Pack怎么设,内存布局都完全一致。

[StructLayout(LayoutKind.Explicit, Size = 24)] public struct CLineAndTheta { [FieldOffset(0)] public float startX; [FieldOffset(4)] public float startY; [FieldOffset(8)] public float endX; [FieldOffset(12)] public float endY; [FieldOffset(16)] public double theta; }

3.4 跨进程传 ref 结构体,必然失败

ref CLineAndTheta在封送时,运行时会把这个结构体的托管副本钉住(pin),然后把它的地址当作lParam传进去。这个地址只在发送进程的地址空间里有效。接收窗口如果在别的进程,它拿到的是一串对自己毫无意义的数字,轻则读到垃圾数据,重则直接访问违例。

原始代码把WM_FIXDATAref CLineAndTheta配在一起用,前提是收发在同一个进程。如果哪天需求变成「通知另一个程序刷新线段」,这条路就走不通了,必须换成WM_COPYDATA,让操作系统帮你把数据从发送进程复制到接收进程。这一步的判断要在写代码前做,不然调三天也调不出来。

4. WM_COPYDATA 与 CopyDataStruct:byte[] 的封送要写死

4.1 dwData、cbData 在 32 位与 64 位下的宽度

原始版本的CopyDataStructIntPtr dwDataint cbDatabyte[] lpData,还额外挂了一个Bitmap tempbmp。原生侧的COPYDATASTRUCT定义是ULONG_PTR dwDataDWORD cbDataPVOID lpData。注意cbDataDWORD,即 32 位无符号,用int接在数值上勉强能用,但语义上应该是uint。更要紧的是dwDataULONG_PTR,x64 下 8 字节,用IntPtr是对的。

cbData填的是字节数,不是元素个数。传一个长度 1024 的byte[]cbData就写 1024,写成 1024 乘元素宽度的话,接收端会读到越界的内存。这个错误在 x86 下经常碰巧不炸,换 x64 就崩,属于典型的「本机好好的,上线就挂」。

4.2 lpData 用 MarshalAs 还是 IntPtr

byte[] lpData不写任何MarshalAs的时候,封送行为依赖于运行时对数组字段的处理规则,不同封送器版本、不同元素类型的表现都可能不一样。这种「能跑但不知道为啥能跑」的状态在排障里最要命,因为你没法确定它是真对了还是碰巧对了。

稳妥的写法是把lpData声明成IntPtr,自己用Marshal.AllocHGlobal分配非托管缓冲区,Marshal.Copy把字节拷进去,发完再FreeHGlobal。这样生命周期完全由你掌控,也不依赖任何默认规则。

[StructLayout(LayoutKind.Sequential)] public struct CopyDataStruct { public IntPtr dwData; public uint cbData; public IntPtr lpData; } public static IntPtr SendCopyData(IntPtr hwnd, IntPtr tag, byte[] payload) { IntPtr buffer = Marshal.AllocHGlobal(payload.Length); try { Marshal.Copy(payload, 0, buffer, payload.Length); var cds = new CopyDataStruct { dwData = tag, cbData = (uint)payload.Length, lpData = buffer }; return SendMessage(hwnd, WM_COPYDATA, UIntPtr.Zero, ref cds); } finally { Marshal.FreeHGlobal(buffer); } }

如果你确实想让托管数组直接参与封送,那就把规则写死,比如[MarshalAs(UnmanagedType.ByValArray, SizeConst = 1024)],并且保证接收端对这个定长数组的处理一致。定长数组是把内容内联进结构体,指针是把地址放进去,两者语义完全不同,接收端读到的东西也完全不同。

4.3 Bitmap 字段必须拿掉

CopyDataStruct里挂一个Bitmap tempbmp,这个字段永远封送不过去。System.Drawing.Bitmap是纯托管对象,没有稳定的原生等价结构,Blittable检查会直接判否,运行时要么抛异常要么把它替换成一个无意义的字段,接收端读到的就不是你想给的东西。

要传图像,先把位图序列化成字节:存进MemoryStream,取ToArray(),然后把这段字节塞进lpData。接收端拿到字节流再自己解码。或者退回 Win32 原生句柄的方案,传HBITMAP(本质是IntPtr),但那样就得处理句柄归属和释放,跨进程还会遇到句柄不通用的问题。

4.4 只能用 SendMessage,不能用 PostMessage

WM_COPYDATA有一条硬规则:必须用SendMessage发送。原因是数据复制发生在发送调用期间,发送方阻塞等待接收方处理完并拷走数据,然后才返回。你用PostMessage的话,消息进了队列就返回了,AllocHGlobal出来的缓冲区可能已经被FreeHGlobal释放,接收方再去读就是野指针。

所以那段try/finally的结构不是风格偏好,是必需的。FreeHGlobal必须在SendMessage返回之后才执行,这一点在原始那段只有一行SendMsg(windowHandler, WM_COPYDATA, 0, ref cds)的写法里完全看不出来——如果当时byte[]是托管数组,靠封送器在调用期间钉住,那还勉强安全;换成手动分配之后,忘了finally就会偶发崩溃。

5. FindWindow 的返回值别用 int:句柄截断会让 SendMsg 回到 0

5.1 类名传 null 与窗口标题的精确匹配

FindWindow(null, "TestMegaugingLib")的意思是类名不限、标题必须精确等于TestMegaugingLib。标题是在窗口创建时定的,如果那个程序在标题后面拼了版本号、拼了当前打开的文件名,你这边写死的字符串就永远匹配不上,FindWindow返回IntPtr.Zero,后面SendMsg(0, ...)自然一路返回 0。

还有一种情况是标题匹配上了,但匹配到的是另一个同名窗口,比如同时开了两个实例。这时候消息发给了错误的那个,接收端没反应,你还是会以为是声明有问题。排查时先把找到的句柄打印出来,再用GetWindowText反查一下,确认拿到的是不是目标窗口。

[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr FindWindow(string lpClassName, string lpWindowName); [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int maxCount);

5.2 IntPtr、SetLastError 与 GetLastWin32Error

FindWindow的返回类型从int改成IntPtr,并且加上SetLastError = true。加上这个属性之后,调用失败时可以用Marshal.GetLastWin32Error()拿到具体的错误码。常见的几个:ERROR_INVALID_WINDOW_HANDLE说明句柄无效;权限相关的错误码则指向 UIPI——当一个普通权限的进程试图给以管理员身份运行的窗口发消息时,系统会直接拦截,SendMessage返回 0,接收端一点动静都没有。

这条比声明错误更难发现,因为代码本身挑不出毛病。遇到「代码明明对,就是没反应」的情况,先确认两边的权限级别是不是一致。

5.3 同名窗口与句柄缓存

有些程序会在运行期间销毁并重建窗口,比如切换主题、重载配置。你在启动时FindWindow拿到的句柄,过一会儿就失效了。这时候SendMessage依然是返回 0,不抛异常。解决办法是在每次发送前重新查找,或者订阅窗口的创建事件刷新缓存。

另一种做法是用EnumWindows遍历所有顶层窗口,逐个比对标题和类名,把符合条件的全列出来。找多个候选的时候,可以在日志里把它们的句柄、类名、标题一起打出来,人工挑一次,比在代码里猜快得多。

6. 改完声明怎么验收:本地跑最小复现,再把结果贴回对话

6.1 让 Codex 出诊断代码,你在本机执行

声明改完之后,让 Codex 帮你生成一个最小的诊断程序:一个只有一个按钮的 WinForms 窗体,点一下依次打印FindWindow的返回值、Marshal.SizeOf(typeof(CLineAndTheta))SendMessage的返回值和Marshal.GetLastWin32Error()。这段代码你拿到本地 Visual Studio 里编译运行,把控制台输出原样贴回对话。

这一步必须由你来做。Codex 不能替你编译、不能替你点按钮,也不能连到你的机器上跑程序。它的作用是读你贴回来的数字,判断偏差出在哪一层,然后给出下一版的声明或者诊断代码。整个循环就是「它改代码、你跑、你把结果贴回去」,转两三圈基本就能定位。

6.2 用尺寸数字和错误码交叉验证

验收时盯两个数字。第一个是Marshal.SizeOf和原生sizeof的差值,差 0 说明布局一致,差 4 或 8 说明字段宽度或者填充有问题。第二个是SendMessage返回值和GetLastWin32Error,返回值是 0 且错误码也是 0,通常意味着消息发出去了、接收端处理了、只是返回值本身恰好是 0,这时候问题已经不在发送端了,去接收端的窗口过程里看。

跨进程的场景还要额外验证一次:把发送端和接收端分别编译成 x86 和 x64,四种组合都跑一遍。句柄宽度、结构体对齐、cbData的截断问题都会在这四组里暴露出来。

6.3 回控制台对一下这次排障的调用

配置和验证都跑过之后,回到 TaoToken 控制台 看一眼这段时间的调用记录,确认 Codex 走的是你填的那条base_url,模型 ID 也是模型广场里真实存在的那个。如果打算长期拿它做这类声明核对、日志分析、报错归因的活,可以顺手看一眼 Coding Plan 的额度是否够用,单独试模型的话在 模型对话 里发一条消息就能验证 Key 和模型 ID 是否配对,需要再建 Key 就去 API Keys 控制台。如果你同时在跑 Claude Code,两边的变量名对照可以翻 接入文档。

SendMessage这类 P/Invoke 的排障有个共同点:错误信息少、返回值单调、失败方式安静。真正省时间的做法不是多试几次签名,而是把「声明对齐、结构体尺寸、句柄有效性、权限级别、跨进程边界」这五件事按顺序排掉,每一项都有可打印的数字可以验证。数字对不上就去改那一层,别再往上一层瞎猜。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询