资讯动态

.NET Core 5.0免注册调用大漠插件:绕开COM注册的桌面自动化方案

发布时间:2026/10/17 8:25:14 来源:尧图企业网站定制
简介这份压缩包为基于.NET Core 5.0的WinForms工程演示在Windows 10下免注册加载大漠插件dm.dll并封装找字、找图、截图、打字等常用接口。面向需要以C#实现自动化测试、图像识别或游戏辅助的桌面端开发者尤其适合希望摆脱管理员权限和注册表依赖的入门与进阶人群。资源共204个文件压缩后约17.47MB其中包含78个dll含主插件及运行库、40个bmp界面/场景图库、15个cs核心调用与窗体代码、13个json及若干配置、缓存、可执行文件等目录结构清晰可直接打开解决方案编译学习。已有902人学习下载。通过工程源码可快速掌握DllImport动态加载、委托绑定、免注册调用大漠API的完整链路同时项目附带的图片资源与示例场景也便于对照验证识别效果节省自行搭建环境的时间。1. 免注册调用大漠插件的 .NET Core 5.0 样板它解决的不只是 COM 注册第一次拿到这个压缩包我差点被名字骗了C#winform-netcore5.0-windows10免注册调用大漠插件.zip看起来像个临时打包的实验品跑起来才发现它是把 Win10 上最麻烦的 COM 免注册调用梳理成了完整可改的工程。之前我在 .NET Framework 4.8 里调用大漠插件只要regsvr32 Dm.dll一条命令就行项目迁到 .NET Core 5.0 后同样的代码在Type.GetTypeFromProgID(dm.dmsoft)这一行直接返回 null连对象都建不出来。这套源码解决的就是这个断层问题。它用 Win32 激活上下文ActCtx在进程内声明 Dm.dll 的 CLSID 和 ProgID不写系统注册表启动时通过CreateActCtxW激活再用 dynamic 去调用 FindWindow、BindWindow、MoveTo、KeyPress 这些接口。适合正在做 .NET Core 迁移、或者想在 Windows 10 上快速搭一套桌面自动化脚手架的开发者——新手可以照着跑通熟手可以直接把它拆成服务层接进自己的工程。2. 环境准备与工程骨架Windows 10 下把项目跑起来2.1 技术栈与前置依赖工程的基座是 .NET 5.0 WinForms目标框架写的是net5.0-windows。为什么不是 .NET 6/8因为这个 demo 是在 Win10 上验证的.NET 5 够用而且 WinForms 在 .NET Core 时代对 COM 互操作的坑最集中用它讲问题最典型。如果你机器上只有新版 SDK用 VS2022 打开.csproj时记得去安装 .NET 5.0 Runtime否则会提示目标框架不受支持。除了 SDK还要准备三个文件dm.dll、Dm.manifest和dmreg.dll。dm.dll是真正的 COM 实现Dm.manifest是免注册调用的身份声明它不是给 C# 看的是给 Windows 的 activation context 用的dmreg.dll在这个工程里并不参与主流程只作为备用方案当 ActCtx 失败时可以降级调用SetDllPathW。源码目录里 thirdparty 文件夹下都放好了运行前把它复制到输出目录。运行环境方面Windows 10 1903 以后的版本基本没差别重点是要确认 Dm.dll 的位数。这个工程默认按 32 位处理所以整个解决方案平台要锁定 x86。Dm.dll 有 32/64 位之分我手头这份是 32 位如果你拿到的不是同一版本后面避坑章节会讲怎么切换。2.2 压缩包里的目录结构解压后目录结构如下DmNet5Winforms/ ├── DmNet5Winforms.sln ├── src/ │ └── DmNet5Winforms/ │ ├── DmNet5Winforms.csproj │ ├── Program.cs │ ├── MainForm.cs │ ├── Loader/ │ │ ├── DmLoader.cs │ │ └── Dm.manifest │ ├── Services/ │ │ └── DmService.cs │ └── Native/ │ ├── ActCtxNative.cs │ └── DmNative.cs ├── thirdparty/ │ ├── dm.dll │ └── dmreg.dll └── README.md这个结构已经很接近一个能维护的工程了不是单文件塞到底。Loader放激活上下文相关代码Services放业务封装Native放 P/Invoke 声明。我第一次改这种项目时把所有 DllImport 堆在 MainForm 里后来加签名、加打包直接把自己绕晕所以这套源码的模块划分我建议保留不要合并。2.3 编译与第一次运行用 VS 打开 sln第一步不是直接 F5而是确认解决方案平台。默认可能是 Any CPU一定要改成 x86菜单生成 - 配置管理器 - 平台下拉框选 x86如果没有就先新建。然后检查Dm.manifest的复制属性把它和 dm.dll 都设为 Copy if newer确保它们出现在 bin 输出目录。按下 F5 启动主窗口只有一个“初始化大漠”按钮。点击它如果输出窗口没有异常按钮会被置灰旁边多出一个4.xxxx的版本号文本。这个版本号来自_dm.Ver()的返回值能走到这一步说明激活上下文生效、类型查找成功、COM 对象创建成功整条免注册链路已经打通。如果按钮没反应打开调试 - Windows - 异常设置勾选 First-chance exceptions 看看。最常见的是ExternalException 0x800401E0 CoInitialize 未调用这是线程模型问题把这个逻辑保持在 WinForms UI 线程内执行就好。后面避坑章节会逐个展开。2.4 工程配置文件详解csproj 的配置值得单独看一眼很多控制台项目或传统 .NET Framework 的配置文件抄过来就翻车就是差在这里Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeWinExe/OutputType TargetFrameworknet5.0-windows/TargetFramework UseWindowsFormstrue/UseWindowsForms PlatformTargetx86/PlatformTarget LangVersion9.0/LangVersion /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.Windows.Compatibility Version5.0.2 / /ItemGroup ItemGroup None IncludeLoader\Dm.manifest CopyToOutputDirectoryPreserveNewest/CopyToOutputDirectory /None None Include..\..\thirdparty\dm.dll CopyToOutputDirectoryPreserveNewest/CopyToOutputDirectory /None /ItemGroup /ProjectPlatformTarget强制 x86这是 32 位 Dm.dll 的硬性要求UseWindowsForms在 .NET 5 里必须显式声明Microsoft.Windows.Compatibility补齐了 .NET Core 上没有的 COM 相关 API 支持。.manifest的拷贝我用的是 None 而不是 Content因为 Dm.manifest 对编译本身不重要但它必须在运行目录里被 CreateActCtxW 找到。3. 免注册加载的核心实现激活上下文、CLSID 与 dynamic 调用3.1 为什么 regsvr32 的注册结果在 .NET Core 下经常失效先看一个典型翻车现场。.NET Framework 时代regsvr32 Dm.dll会把 COM 信息写入注册表然后 C# 里Type.GetTypeFromProgID去注册表里按 ProgID 找到 CLSID创建实例。到了 .NET Core 5.0这个过程没有本质变化但 Win10 对 32 位注册表做了重定向如果你在 64 位系统上跑 32 位 Dm.dllregsvr32会写进 WOW6432Node 节点而 .NET 应用的位数一旦不同读的注册表视图就对不上于是返回 null。免注册 COM 的思路是绕开注册表这个共享状态。Windows 允许你用激活上下文指定一个 manifestmanifest 里声明某个 CLSID 对应哪个目录下的哪个 DLL。当线程激活了这个上下文后COM 运行时在解析 CLSID 时会先去上下文里找找不到才去注册表找。这套源码里的DmLoader就是干这件事的。3.2 DmLoader激活上下文的封装代码DmLoader.cs是这个工程的核心代码量不大但每行都有讲究using System; using System.ComponentModel; using System.Runtime.InteropServices; namespace DmNet5Winforms.Loader { internal sealed class DmLoader : IDisposable { private IntPtr _hActCtx IntPtr.Zero; private IntPtr _cookie IntPtr.Zero; [StructLayout(LayoutKind.Sequential, CharSet CharSet.Unicode)] private struct ACTCTXW { public int cbSize; public uint dwFlags; public string lpSource; public ushort wProcessorArchitecture; public ushort wLangId; public string lpAssemblyDirectory; public string lpResourceName; public string lpApplicationName; } [DllImport(kernel32.dll, CharSet CharSet.Unicode, SetLastError true)] private static extern IntPtr CreateActCtxW(ref ACTCTXW pActCtx); [DllImport(kernel32.dll, SetLastError true)] private static extern bool ActivateActCtx(IntPtr hActCtx, out IntPtr lpCookie); [DllImport(kernel32.dll, SetLastError true)] private static extern bool DeactivateActCtx(uint dwFlags, IntPtr lpCookie); [DllImport(kernel32.dll)] private static extern void ReleaseActCtx(IntPtr hActCtx); public DmLoader(string manifestPath) { var ctx new ACTCTXW(); ctx.cbSize Marshal.SizeOf(typeof(ACTCTXW)); ctx.lpSource manifestPath; _hActCtx CreateActCtxW(ref ctx); if (_hActCtx new IntPtr(-1) || _hActCtx IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); } public void Activate() { if (!ActivateActCtx(_hActCtx, out _cookie)) throw new Win32Exception(Marshal.GetLastWin32Error()); } public void Deactivate() { if (_cookie ! IntPtr.Zero DeactivateActCtx(0, _cookie)) _cookie IntPtr.Zero; } public void Dispose() { Deactivate(); if (_hActCtx ! IntPtr.Zero) { ReleaseActCtx(_hActCtx); _hActCtx IntPtr.Zero; } } } }这里最容易漏的是 CreateActCtxW 的返回值判断。很多网上的例子只判 IntPtr.Zero但 Windows 在失败时经常返回INVALID_HANDLE_VALUE也就是-1。不判负数后面 ActivateActCtx 就会传一个无效句柄进去。ACTCTXW的cbSize必须初始化成结构体大小我第一次少给这一行CreateActCtxW 直接返回 87 参数错误。lpSource是 manifest 的完整路径最好用AppContext.BaseDirectory拼出来因为工作目录未必等于运行目录。ActivateActCtx的第二个参数是 cookie这个值必须原样保存后面DeactivateActCtx(0, _cookie)再传回去。cookie 相当于锚点如果把激活上下文比作一个栈cookie 就是你的栈帧标记释放时机错了会影响同一线程上其他 COM 调用。3.3 Dm.manifest免注册 COM 的身份声明Dm.manifest的内容决定了 Dm.dll 会被映射成哪个 ProgID?xml version1.0 encodingUTF-8 standaloneyes? assembly xmlnsurn:schemas-microsoft-com:asm.v1 manifestVersion1.0 file nameDm.dll comClass clsid{A1A1A1A1-A1A1-A1A1-A1A1-A1A1A1A1A1A1} progiddm.dmsoft threadingModelApartment descriptionDm Automation Component / /file /assembly注意 file 元素的 name 是相对路径组件会在这个 manifest 所在目录下去找 Dm.dll。clsid 不能乱写要和 Dm.dll 内部 TypeLib 的 GUID 一致。常见做法是先在测试机regsvr32 Dm.dll注册一次然后打开注册表编辑器去HKEY_CLASSES_ROOT\WOW6432Node\CLSID里搜 progid 对应的 CLSID 抄出来。更严谨的做法是用 TypeLib 信息读 DLL 里的 CLSID但工程里注释已经给了抄注册表的位置我一般直接抄抄完就不动。3.4 DmService从 ProgID 到 dynamic 实例拿到激活上下文后创建对象就顺理成章了。工程里的DmService.cs做了一层薄封装using System; using System.IO; using System.Runtime.InteropServices; using DmNet5Winforms.Loader; namespace DmNet5Winforms.Services { public class DmService { private dynamic _dm; public void Initialize(string manifestDir) { var manifestPath Path.Combine(manifestDir, Dm.manifest); using var loader new DmLoader(manifestPath); loader.Activate(); Type type Type.GetTypeFromProgID(dm.dmsoft); if (type null) throw new COMException(免注册上下文未生效找不到 dm.dmsoft); _dm Activator.CreateInstance(type); } public string GetVersion() _dm.Ver().ToString(); public string GetLastError() _dm.GetLastError().ToString(); public dynamic Raw _dm; } }这里的关键是Type.GetTypeFromProgID必须在Activate之后调用而且要在同一个线程。如果你把 Activate 放在一个后台线程再回到 UI 线程去 GetTypeFromProgID激活上下文已经失效了。我在这地方翻过车为了不卡界面把初始化丢到 Task.Run 里结果dm.dmsoft死活找不到后来才明白激活上下文跟线程绑定不是全局的。Activator.CreateInstance(type)返回 object赋值给 dynamic 之后后续调用的所有方法都会在运行时绑定。这个绑定依赖Microsoft.CSharp它是 .NET 5 Runtime 自带的但如果你手动裁剪依赖记得别把这一块剪掉。拿不到版本号时先看 GetLastError也能暴露原因。3.5 释放顺序先拆对象再拆上下文很多人在程序退出时崩溃问题都出在释放顺序。动态对象_dm本质是一个 RCWRuntime Callable Wrapper它内部持有 COM 指针loader持有的激活上下文又是 COM 解析时的查找表。如果先 Dispose loader再调用_dmCOM 对象还在但下次解析接口时会断掉引用轻则返回 E_UNEXPECTED重则进程直接崩溃。所以工程里 MainForm 关闭时做了两步先把_dm null让 GC 回收 RCW再调 loader 的 Dispose。你在自己的代码里可以用一个方法统一处理不要依赖 using 块的声明顺序。因为 using var 的释放顺序是反向的如果你先声明 loader 再创建 _dmDispose 时反而先释放 loader这正是踩坑点。4. 功能模块拆解从取窗口句柄到鼠标键盘自动化4.1 用 FindWindow 定位目标窗口大漠插件最常用的入口是 FindWindow。它可以从类名和标题两个维度找到顶层窗口句柄long hwnd _dm.FindWindow(Notepad, null); if (hwnd 0) { int errorCode _dm.GetLastError(); MessageBox.Show($查找失败错误码{errorCode}); }第一个参数是窗口类名比如记事本是Notepad第二个参数是窗口标题。填写时二选一另一个传 null。返回值是窗口句柄但这里有个坑大漠的 FindWindow 返回 0 通常表示失败因为 0 不是有效句柄。注意大漠 COM 接口返回的是 long 而不是 IntPtr不要用IntPtr.Zero去比。如果你的目标窗口标题会变找窗口别用标题。我用窗口类名或者进程主窗口句柄更稳但这个小工程里只演示了最直接的查找。实际项目建议把 FindWindow 封装成重试逻辑因为窗口可能还没弹出来需要轮询几次。4.2 BindWindow 后台绑定模式找到窗口句柄后前台操作可以直接用 MoveTo但如果窗口被遮挡后台绑定才是自动化项目真正需要的。BindWindow 的参数很讲究bool ok _dm.BindWindow(hwnd, gdi, dx, dx, 0) 1; if (ok) { _dm.SendString(hwnd, 后台输入测试); }参数分别是 hwnd、显示器绑定模式、鼠标绑定模式、键盘绑定模式、pub 参数。这里gdi表示用 GDI 截屏做图像分析鼠标键盘用dx模式能通过 DX 接口往后台发指令。模式不是越高级越好dx对部分窗口无效尤其对内部有自绘的窗口要回退到normal。我见过很多二次开发把参数抄错比如第三个参数传了显示器模式调用返回 0 还不知道为什么。如果 BindWindow 返回 0用 GetLastError 看错误码。这个工程在状态栏里把错误码打出来了排查会方便很多。4.3 MoveTo 与 LeftClick前台鼠标模拟桌面自动化最典型的组合是移动鼠标再点击_dm.MoveTo(640, 480); Thread.Sleep(200); _dm.LeftClick();MoveTo 的坐标是屏幕绝对坐标不是窗口内相对坐标。如果你做了 DPI 缩放这个坐标会和视觉上对不上后面避坑章节会专门记。LeftClick 和 LeftDoubleClick 同理点击前最好做一次鼠标是否移动到位的手动观察因为桌面分辨率一变坐标就错。这里有个我踩过的玄学在 Windows 10 上如果程序不是以管理员权限运行有些窗口会拦截鼠标模拟。看起来调用没报错但演示窗口没有任何反应。可以先跑一次管理员身份把权限因素排除。4.4 组合演示启动记事本并输入一段文字把这几个接口串起来就是一个最小的自动化用例long hwnd _dm.FindWindow(Notepad, null); if (hwnd 0) { _dm.RunApp(notepad.exe); Thread.Sleep(1200); hwnd _dm.FindWindow(Notepad, null); } _dm.SendString(hwnd, hello from dm);RunApp会用 ShellExecute 启动外部程序启动后需要等窗口创建完成这里用 Sleep 是最粗糙的等待。实际开发我会换成 WaitForWindow大漠也提供这类 API但示例里注释写得清楚Sleep 只用于验证。SendString 在绑定模式下可以直接输入也可以配合 KeyPress 逐字输入。整套链路到这里已经覆盖了大漠插件最核心的三类操作窗口查找、窗口绑定、键鼠模拟。后面要做更复杂的识别、找色、OCR都是在dynamic dm上继续往下挂方法格式一致。5. 避坑记录Windows 10 兼容性问题排查手册5.1 激活上下文无效ProgID 一直解析不了现象Initialize 没有抛异常但Type.GetTypeFromProgID(dm.dmsoft)返回 null用 GetLastError 查询是 0什么错误码都没有。原因最常见的是 manifest 没有复制到输出目录CreateActCtxW 加载到了不存在路径或者线程上下文没有激活。注意 ActivateActCtx 的 cookie 属于当前线程不能跨线程使用。解决先把Dm.manifest的复制属性设为 Copy if newer再用Path.Combine(AppContext.BaseDirectory, Dm.manifest)拼路径避免工作目录不等于 bin 目录。在 Activate 之后立刻写一行日志输出Marshal.GetLastWin32Error()确认激活成功。我因为这个吃了半天亏后来统一在 DmLoader 的 Activate 里加了一个断言。5.2 切换 x64 后所有接口返回 0现象源码没改只是把解决方案平台从 x86 切到 x64编译通过运行也能初始化但 FindWindow、MoveTo 全部返回 0。原因Dm.dll 是 32 位进程内 COM 组件。x64 进程无法加载 32 位 COM DLL但激活上下文在 x64 下仍然能被创建Type.GetTypeFromProgID 甚至能找到类型结果创建对象时 CLR 加载 DLL 失败后面所有调用全部失败。解决检查 thirdparty 里的 Dm.dll 是 32 位还是 64 位。32 位就把 csproj 的PlatformTargetx86/PlatformTarget锁死且 Configuration Manager 中解决方案平台也必须是 x86。如果 Dm.dll 是 64 位则改成 x64同时 manifest 里file nameDm.dll不变。切记不能 Any CPU因为 Any CPU 进程在优先 x64 时同样加载不了 32 位 DLL。5.3 DPI 缩放导致鼠标坐标偏移现象Win10 显示设置里缩放 125% 或 150%MoveTo 的坐标比实际位置偏点击不到目标按钮。原因进程没有声明 DPI 感知Windows 把鼠标坐标从物理像素虚拟化成了逻辑像素。大漠插件里部分接口拿着逻辑坐标去映射屏幕导致位移。解决在 Main 入口调用SetProcessDpiAwarenessContext。工程里已经在 Program.cs 加了这行[DllImport(user32.dll, SetLastError true)] static extern bool SetProcessDpiAwarenessContext(IntPtr value); SetProcessDpiAwarenessContext(new IntPtr(-4)); // PerMonitorV2-4 是DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2在 Windows 10 1703 以上有效。注意这个调用必须在创建任何窗口之前。如果你用了 WinForms 的 AutoScaleMode还要在 Designer 里保持一致否则界面字体和坐标会一起乱。5.4 退出时偶发 AccessViolation现象主窗口关闭时有一定概率崩溃崩溃栈指向 kernel32 或 RCW 释放相关代码。原因清理顺序错了。using var loader在离开作用域时会先释放 loader再释放_dm但_dm的 RCW 还持有大漠接口激活上下文已经没了RCW 在后析构时再解析就爆了。解决在所有退出路径里先显式_dm null再释放 loader。可以包一层作用域dmLoader new DmLoader(manifestPath); dmLoader.Activate(); _dm CreateDmInstance(); // 退出时 _dm null; dmLoader.Deactivate(); dmLoader.Dispose();不要在 using 块里返回 _dm也不要把 _dm 塞到另一个类后不管。从那以后我每次写完都会把释放顺序当代码评审的重点。6. 二次开发思路把大漠包装成自己的自动化服务6.1 用接口隔离 dynamic如果只在 MainForm 里用 dynamic项目跑通没问题但要接进自己的业务系统直接裸奔会很难测试。我的建议是定义一个接口把用到的几个接口约束出来public interface IAutomation { long FindWindow(string className, string title); bool BindWindow(long hwnd, string display, string mouse, string key, int pub); void MoveTo(int x, int y); void LeftClick(); void SendString(long hwnd, string text); }然后在 DmService 里加一个 Adapterpublic class DmAdapter : IAutomation { private readonly dynamic _dm; public DmAdapter(dynamic dm) _dm dm; public long FindWindow(string cls, string title) _dm.FindWindow(cls, title); public bool BindWindow(long hwnd, string display, string mouse, string key, int pub) _dm.BindWindow(hwnd, display, mouse, key, pub) 1; }这样上层代码不用感知 dynamic也能 mock 接口来做单元测试。二次开发时每加一个大漠接口就往接口和 Adapter 里同步加一行误差可控。6.2 把错误码封装成异常dynamic 方法的返回值是 long 或 string失败时返回 0 或空串。如果每次都判断代码就会像if (_dm.FindWindow(...) 0)满天飞。我一般会在 Adapter 里做一个统一检查凡是返回 0 的方法都调用一次 GetLastError然后抛一个DmException。public long FindWindowOrThrow(string cls, string title) { long hwnd _dm.FindWindow(cls, title); if (hwnd 0) throw new DmException($FindWindow failed, error{_dm.GetLastError()}); return hwnd; }再用 try/catch 包在业务层日志里就是一行带着错误码的记录而不是在界面上弹一堆 0 和 -1。这个习惯帮我省了大量排查时间。6.3 验证习惯改完工程我每次都会做三件事先在 x86 配置下跑一遍初始化再开一个记事本跑 FindWindow - BindWindow - SendString 全流程最后用进程监视工具确认没有注册表写入。三个验证都过这份压缩包才算吃透了。有一次我连续改了三次 manifest第三次改完怎么也加载不了最后发现是 clsid 多打了一个空格。从那以后我每次改完都会强制走一遍上面的流程用日志把每次 Activate 输出的路径和错误码打出来确认不是凭空猜的。这套源码的价值不在代码量而在把免注册调用这个黑匣子变成了可复现的流程希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑