
简介这份压缩包面向希望在 C# WinForm 桌面项目中调用大漠插件dm.dll的开发者重点演示在 .NET Core 5.0 与 Windows 10 环境下免注册加载插件、调用找字、找图、截图、打字等能力的完整工程示例。包内共 204 个文件以 78 个 dll、40 个 bmp、15 个 cs 及 json、pdb、so 等为主包含示例源码、界面资源、图片模板和依赖库整体约 17.47MB便于对照目录理解项目结构。该资源已有 905 人学习适合需要减少注册表依赖、简化部署并排查动态加载与资源释放问题的读者参考。借助现成工程可快速了解 WinForm 界面事件绑定、DllImport 与 LoadLibrary 调用方式、图片素材组织及多线程与错误处理思路从而在自己的自动化识别场景中复用关键实现。1. 一个 0x80040154 报错把这套免注册方案逼了出来一个 .NET 5 的 C# WinForms 工具从旧框架迁到 Windows 10 之后界面能起来一点「开始」就抛 0x80040154「类未注册」。原因不复杂dm.dll 是 COM 组件老代码靠 regsvr32 把 CLSID 写进注册表而新机器没有管理员权限32 位和 64 位两份 dll 还互相覆盖。所谓免注册调用大漠插件就是把「注册到系统」改成「进程内自己激活」用 LoadLibrary 把 dm.dll 载进当前进程取 DllGetClassObject手动走一遍 COM 激活拿到 IDispatch之后所有方法调用都走这条链路。它适合三类人要做桌面客户端自动化回归测试的、想把脚本工具打包成绿色版直接发给别人的、以及在 CI 机器上没法改注册表还想跑 UI 自动化的。读完至少能拿到一条能落地的加载链路、一份可抄的参数封装以及一份让我少熬两个晚上的排查清单。2. 工程骨架与技术选型为什么把它拆成四个项目先看目录。这套结构不是为了好看是因为 COM 互操作一旦散落在窗体代码里后面换插件版本、加单元测试、把工具打包发给别人每一步都会疼。2.1 目录骨架把 COM 互操作锁在单独一层DesktopAuto/ ├─ DesktopAuto.sln ├─ src/ │ ├─ DesktopAuto.App/ # WinForms 宿主只管界面与生命周期 │ │ ├─ Program.cs │ │ ├─ MainForm.cs │ │ ├─ app.manifest │ │ └─ DesktopAuto.App.csproj │ ├─ DesktopAuto.Automation/ # 与具体插件无关的抽象层 │ │ ├─ IAutomationEngine.cs │ │ ├─ AutomationResult.cs │ │ └─ DesktopAuto.Automation.csproj │ ├─ DesktopAuto.Automation.Dm/ # dm.dll 的加载与调用封装 │ │ ├─ DmLoader.cs │ │ ├─ DmEngine.cs │ │ ├─ DmOptions.cs │ │ └─ Interop/ │ │ ├─ IClassFactory.cs │ │ ├─ DllGetClassObjectFn.cs │ │ └─ PeImage.cs │ └─ DesktopAuto.Automation.Fake/ # 测试替身CI 里完全不碰真 dll ├─ native/ │ ├─ x86/dm.dll │ └─ x64/dm.dll └─ tests/ └─ DesktopAuto.Tests/Automation这一层只放接口和数据结构不引用任何 InteropAutomation.Dm是唯一知道 CLSID、唯一调用NativeLibrary.Load的地方App通过构造函数注入IAutomationEngine它压根不知道底下是 dm.dll 还是别的实现。这个拆法带来的直接好处是CI 流水线上跑Automation.Fake就能把业务流程测完不需要 32 位宿主、不需要 dll、不需要管理员权限。我见过不少同类工具把所有东西糊在一个 Form 里dynamic dm ...从按钮点击一直写到定时器回调。跑是能跑但换一个插件版本、或者客户机器上 dll 位数不对报错堆栈会直接指向窗体事件根本没法定位是哪一步激活失败。native目录同时留 x86 和 x64 两份占位是因为发布时你没法预知目标机器上跑的是哪个版本的工具。文件名保持dm.dll不变只按目录区分加载器按当前进程位数拼路径逻辑最简单。2.2 技术选型net5.0-windows、x86 与 STA 三件事目标框架必须是net5.0-windows不是net5.0。WinForms 的引用挂在 Windows 目标框架标识下写成net5.0连UseWindowsForms都不生效另外像Marshal.GetObjectForIUnknown、NativeLibrary这些 API 都带 Windows 平台标注用net5.0-windows才不会被一堆 CA1416 警告糊脸。位数是第一优先级。dm.dll 是 32 位宿主进程就得是 32 位是 64 位宿主就得 64 位。位数不匹配时LoadLibrary直接返回BadImageFormatExceptionHRESULT 0x8007000B这个错误信息跟「找不到文件」完全是两回事很多人第一次遇到以为是路径写错了查半天。SDK 风格项目有个容易翻车的点老 csproj 里Prefer32Bit默认是 trueAnyCPU 编译出来会跑成 32 位到了 SDK 风格项目AnyCPU 默认跟着操作系统走64 位系统上就是 64 位进程。同一份代码迁过来行为悄悄变了。我的做法是干脆不写 AnyCPU在 csproj 里把PlatformTarget写死成x86同时在启动时读一次 dll 的 PE 头做校验位数不对直接给出人话提示而不是把异常丢给用户。线程模型是第二个优先级。大漠的 COM 对象不是自由线程的跨 apartment 调用会被系统 marshal 一层性能掉得厉害个别跟窗口、剪贴板相关的接口还会直接失败。WinForms 主线程默认 STA直接在上面调没问题但找图找色动辄几百毫秒全放主线程 UI 就假死了。正确做法是起一条专用的 STA 工作线程所有插件调用都在这条线程上排队执行主线程只负责收结果刷界面。// 专用 STA 工作线程绝不要把 dm 调用丢进 ThreadPool var worker new Thread(WorkerLoop) { IsBackground true, Name automation-worker }; worker.SetApartmentState(ApartmentState.STA); // 线程池线程是 MTA跨 apartment 会踩坑 worker.Start();SetApartmentState必须在Start()之前调用启动之后再设会抛InvalidOperationException。线程池线程的 apartment 固定为 MTA改不了所以别想着用Task.Run包一层就完事。至于不用 C/CLI 中间层包一层、也不用手写ComImport强类型接口理由很实际前者发布时要带一堆原生运行时绿色版就做不成了后者要把 IDispatch 派生接口的全部方法按 vtable 顺序逐个声明大漠暴露的方法数量摆在那维护成本远超收益。统一走 late binding用InvokeMember派发是最省心的路子。2.3 csproj 与 app.manifest 的两处关键配置Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeWinExe/OutputType TargetFrameworknet5.0-windows/TargetFramework UseWindowsFormstrue/UseWindowsForms !-- dm.dll 是 32 位整个进程就必须跟着 32 位别留 AnyCPU -- PlatformTargetx86/PlatformTarget Platformsx86/Platforms Nullableenable/Nullable ApplicationManifestapp.manifest/ApplicationManifest /PropertyGroup /ProjectPlatformTarget和Platforms两个都要写。前者决定编译进程序集的 PE 头后者决定 IDE 和 MSBuild 的解决方案配置里能不能选到 x86只写一个会出现在 VS 里能编译但输出仍是 AnyCPU 的情况。清单文件里我只关心两件事执行级别和 DPI。?xml version1.0 encodingutf-8? assembly manifestVersion1.0 xmlnsurn:schemas-microsoft-com:asm.v1 assemblyIdentity version1.0.0.0 nameDesktopAuto.App/ trustInfo xmlnsurn:schemas-microsoft-com:asm.v2 security requestedPrivileges xmlnsurn:schemas-microsoft-com:asm.v3 !-- 不提权避免 UAC 弹窗打断自动化流程目标程序若以管理员运行则另说 -- requestedExecutionLevel levelasInvoker uiAccessfalse / /requestedPrivileges /security /trustInfo application xmlnsurn:schemas-microsoft-com:asm.v3 windowsSettings !-- DPI 不声明高缩放屏上截图像素和窗口坐标对不上找图必然偏 -- dpiAware xmlnshttp://schemas.microsoft.com/SMI/2005/WindowsSettingstrue/dpiAware dpiAwareness xmlnshttp://schemas.microsoft.com/SMI/2016/WindowsSettingsPerMonitorV2/dpiAwareness /windowsSettings /application /assemblydpiAware这一行不是可选项。系统缩放 125% 或 150% 时如果不声明 DPI 感知进程会被系统做位图拉伸GetWindowRect拿到的是逻辑坐标Capture截出来的是物理像素两套坐标混用找图就永远差那么几十个像素而且在你自己的开发机100% 缩放上完全复现不出来。3. 免注册调用核心链路从 LoadLibrary 到 IDispatch这一章是整套方案的心脏。搞明白这三步后面所有封装都是锦上添花。3.1 手动激活到底省掉了什么正常 COM 调用是CoCreateInstance(clsid, ...)系统拿 CLSID 去注册表HKCR\CLSID\{...}\InprocServer32查 DLL 路径加载、调用DllGetClassObject、拿类厂、创建实例一条龙。免注册的意思不是绕过 COM而是跳过「查注册表」这一步剩下的活自己干。dm.dll作为标准 inproc COM 服务器必然导出这个函数HRESULT DllGetClassObject(REFCLSID rclsid, REFIID riid, void **ppv);第一个参数是想要哪个类的 CLSID第二个是想要类厂暴露哪个接口一般传IID_IClassFactory第三个是输出指针。拿到类厂之后调CreateInstance传IID_IDispatch就能拿到一个 late binding 可用的 dispatch 指针。这里有个关键简化我们不需要知道大漠自定义的任何接口 IID。因为全程只用IDispatchCreateInstance时riid固定传{00020400-0000-0000-C000-000000000046}就行。省掉了一大块从类型库里挖 IID 的活。3.2 三个必须手写的 interop 声明using System; using System.Runtime.InteropServices; namespace DesktopAuto.Automation.Dm.Interop { // COM 类厂只声明我们真正会调的两个方法顺序必须是 vtable 顺序 [ComImport] [Guid(00000001-0000-0000-C000-000000000046)] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IClassFactory { [PreserveSig] int CreateInstance(IntPtr pUnkOuter, ref Guid riid, out IntPtr ppv); [PreserveSig] int LockServer([MarshalAs(UnmanagedType.Bool)] bool fLock); } // DllGetClassObject 的导出签名stdcall返回 HRESULT [UnmanagedFunctionPointer(CallingConvention.StdCall)] internal delegate int DllGetClassObjectFn( ref Guid rclsid, ref Guid riid, out IntPtr ppv); }[PreserveSig]不能省。默认情况下运行时会把这些返回 HRESULT 的方法翻译成「失败就抛异常」成功时把返回值丢掉。但我们后面要按 HRESULT 分支给出不同提示保留原始返回值更好排查。IClassFactory里方法顺序必须和原生 vtable 一致QueryInterface/AddRef/Release由InterfaceIsIUnknown自动补接口里声明的第一个方法对应 vtable 第四项也就是CreateInstance。顺序写反了不会编译报错但运行时调用会跳到错误的函数地址表现是进程直接崩或者返回一堆莫名其妙的内存错误。CallingConvention.StdCall也要对上。COM 在 Windows 上默认就是 stdcall写成Cdecl会让参数出栈方错位症状是第一次调用能过、第二次调用栈就歪了。3.3 DmLoader加载、激活、缓存using System; using System.IO; using System.Runtime.InteropServices; using DesktopAuto.Automation.Dm.Interop; namespace DesktopAuto.Automation.Dm { public sealed class DmLoader : IDisposable { // IDispatch 的 IID全程走 late binding不需要知道插件的自定义接口 private static readonly Guid IID_IDispatch new Guid(00020400-0000-0000-C000-000000000046); private static readonly Guid IID_IUnknown new Guid(00000000-0000-0000-C000-000000000046); private IntPtr _hModule IntPtr.Zero; private object? _instance; public object Instance _instance ?? throw new InvalidOperationException(尚未加载 dm.dll); public void Load(string dllPath, Guid clsid) { if (!File.Exists(dllPath)) throw new FileNotFoundException(找不到插件 dll, dllPath); // 位数不匹配时给出人话提示而不是让 BadImageFormatException 冒到界面上 var is32 PeImage.Is32Bit(dllPath); if (is32 ! (IntPtr.Size 4)) throw new PlatformNotSupportedException( $dll 是 {(is32 ? 32 : 64)} 位当前进程是 {IntPtr.Size * 8} 位); _hModule NativeLibrary.Load(dllPath); // 比 LoadLibrary P/Invoke 更干净 try { var export NativeLibrary.GetExport(_hModule, DllGetClassObject); var fn Marshal.GetDelegateForFunctionPointerDllGetClassObjectFn(export); // 第一步拿类厂 var hr fn(ref clsid, ref IID_IUnknown, out var pUnk); if (hr ! 0) throw new COMException(DllGetClassObject 失败, hr); var factory (IClassFactory)Marshal.GetObjectForIUnknown(pUnk); Marshal.Release(pUnk); // 第二步创建实例rio 固定传 IDispatch hr factory.CreateInstance(IntPtr.Zero, ref IID_IDispatch, out var pDisp); Marshal.ReleaseComObject(factory); if (hr ! 0) throw new COMException(CreateInstance 失败, hr); // 第三步转成 RCW后续用 InvokeMember 派发 _instance Marshal.GetObjectForIUnknown(pDisp); Marshal.Release(pDisp); } catch { NativeLibrary.Free(_hModule); _hModule IntPtr.Zero; throw; } } public void Dispose() { if (_instance ! null) { Marshal.FinalReleaseComObject(_instance); // 先放 COM 对象 _instance null; } if (_hModule ! IntPtr.Zero) { NativeLibrary.Free(_hModule); // 再卸载 dll _hModule IntPtr.Zero; } } } }参数说明几处。clsid从配置读不硬编码在代码里因为不同版本、不同位数的插件可能给出不同的 CLSID写死之后换版本就得重新编译。Marshal.GetObjectForIUnknown返回的是 RCW把它 cast 到IClassFactory会走一次QueryInterface这是合法的因为接口上标了ComImport和 GUID。释放顺序不能反。先FinalReleaseComObject把 RCW 的引用计数清零让插件对象析构再NativeLibrary.Free卸载模块。反过来会出现 dll 里还有对象活着但模块已被卸载的情况后续任何一次调用都是野指针症状是毫无规律的访问冲突。PeImage.Is32Bit单独写了个小工具读 PE 头比在运行时撞异常体验好得多public static bool Is32Bit(string path) { using var fs File.OpenRead(path); using var br new BinaryReader(fs); fs.Position 0x3C; // DOS 头里 e_lfanew 的位置 fs.Position br.ReadInt32() 4; // 跳过 PE\0\0 var machine br.ReadUInt16(); // 0x014c x860x8664 x64 return machine 0x014c; }3.4 CLSID 从哪来开发期提取一次运行期读配置这是新手最容易卡住的地方免注册了可 CLSID 还是得知道。我的做法是在开发机上一次性地注册一次把 CLSID 抄出来之后目标机器上再也不需要注册。# 32 位 dll 必须用 SysWOW64 下的 regsvr32直接敲 regsvr32 会调 64 位版本而失败 C:\Windows\SysWOW64\regsvr32.exe /s .\native\x86\dm.dll # 从 ProgID 反查 CLSID 并打印 (Get-ItemProperty Registry::HKEY_CLASSES_ROOT\dm.dmsoft\CLSID).(default) # 抄完立刻反注册保证开发机也处于「未注册」状态验证免注册链路真的成立 C:\Windows\SysWOW64\regsvr32.exe /s /u .\native\x86\dm.dll把打印出来的 GUID 写进appsettings.json或者一个dm.config.json加载器启动时读。反注册这一步别省很多人开发机上一直留着注册表项代码里其实有 bug 也测不出来一发到客户机器上就炸。提示ProgID 名字以 dll 里实际注册的为准不同打包版本可能不一样。提取不到就换个思路用系统自带的 OLE/COM 查看器按类型库翻一遍找到那个 coclass 抄 CLSID。4. 自动化能力落地绑定、找图与键鼠封装加载器跑通只算过了一半真正干活的是后面这些调用。这一章把常用能力按「先能调通、再调稳」的顺序拆开。4.1 窗口查找与绑定三个模式参数怎么选绑定是后续所有操作的前提绑定失败后面全是白搭。典型写法是先按类名和标题找窗口句柄再绑public sealed class DmEngine : IAutomationEngine { private readonly object _dm; private readonly Type _dmType; public DmEngine(object dm) { _dm dm; _dmType dm.GetType(); // RCW 的类型是 __ComObjectInvokeMember 会走 IDispatch } private object Invoke(string method, params object[] args) _dmType.InvokeMember(method, BindingFlags.InvokeMethod, null, _dm, args); public IntPtr FindWindow(string className, string title) new IntPtr(Convert.ToInt64(Invoke(FindWindow, className, title))); public bool BindWindow(IntPtr hwnd, DmBindOptions opt) { var ret Convert.ToInt32(Invoke( BindWindow, hwnd.ToInt32(), opt.Display, opt.Mouse, opt.Keypad, opt.Mode)); return ret 1; // 1 成功0 失败失败时先 UnBindWindow 再换模式重试 } } public sealed class DmBindOptions { public string Display { get; set; } normal; // 截图方式 public string Mouse { get; set; } normal; // 鼠标模拟方式 public string Keypad { get; set; } normal; // 键盘模拟方式 public int Mode { get; set; } 0; // 组合模式不同版本含义有差异 }三个字符串参数决定「用什么方式读屏、用什么方式发消息」。normal是最通用也最容易被目标程序干扰的一种走常规窗口消息和 GDI 截屏遇到画面刷不出来的场景通常要往gdi、windows、dx这一档去试。Mode是个位标志组合不同插件版本里的取值含义不完全一致我的习惯是从 0 开始绑定失败就按候选表逐个试返回 1 立刻停。// 绑定模式候选按「干扰最小」到「兼容性最强」的顺序试 private static readonly int[] ModeCandidates { 0, 1, 2, 101, 103 }; public bool TryBind(IntPtr hwnd) { foreach (var mode in ModeCandidates) { if (BindWindow(hwnd, new DmBindOptions { Mode mode })) return true; Invoke(UnBindWindow); // 上次失败可能留下半绑定状态必须先解 } return false; }UnBindWindow这一步经常被漏掉。绑定失败后直接换参数再绑插件内部可能残留上一次的部分状态导致后面怎么试都失败重启工具又好了很容易被误判成「随机失败」。还有个前提条件容易被忽略目标程序如果是以管理员权限启动的而你的自动化工具是普通权限Windows 的 UIPI 机制会直接拦掉消息发送绑定表面成功、键鼠完全没反应。这种时候两边权限必须对齐本文还有配套的精品资源点击获取