如何调试部署后由 COM 触发的 AccessViolationException?
结论
这类故障应先按原生代码或 COM 边界发生内存破坏来排查,不能当作普通的 C# 异常处理。
从现有代码判断,COM apartment 模型是首要检查项。WPF UI 线程通常是 STA,Task.Run 和线程池线程通常是 MTA。许多桌面自动化 COM server 要求对象在 STA 线程中创建,后续调用也必须回到同一个 STA 线程。把 COM 引用保存在 static 字段中,再从不同的 Task 或线程访问,很容易破坏这一约束。
下面这行代码也不是普通的字段赋值:
App.Visible = true;
它会跨越 COM 边界执行属性 setter。日志停在这行之前,只能说明崩溃发生在 setter 内部、COM proxy/stub、COM server 的回调中,或由此前的内存破坏延迟触发。不能据此判断 COM 尚未被调用。
建议按以下顺序排查:
- 确认崩溃的是 WPF 客户端还是独立的 COM server 进程。
- 将所有 COM 操作移到一个带消息循环的专用 STA 线程,禁止其他线程直接访问该对象。
- 核对客户端、COM server、proxy/stub 和注册信息的位数与版本。
- 收集完整用户模式转储,同时启用托管和原生调试。
- 使用与部署版本完全匹配的 EXE、DLL、PDB 和源代码分析转储。
先修正代码中的两个问题
示例中的空值判断误写成了赋值:
if (App = null)
如果实际代码也是这样,应改为:
if (App == null)
也可以使用模式匹配:
if (App is null)
成功创建 RCW 也不能说明 COM server 已经完成初始化。某些自动化服务器会在构造结束后继续启动进程、加载插件、建立 RPC 通道或发起回调,所以“构造函数返回几秒后崩溃”仍可能与初始化过程有关。
不要在线程池线程间共享 COM 对象
Task 并不等同于专用后台线程。未指定调度器时,Task 通常在线程池的 MTA 线程上运行。遇到 await 后,后续代码还可能切换到另一个线程。
以下设计风险较高:
public static ComApplication App { get; set; }
多个 Task 读取这个静态引用时,调用可能来自不同线程。即使 COM marshalling 能代理部分调用,第三方 COM server 也未必支持这种使用方式。
更稳妥的做法是创建一个运行消息循环的专用 STA 线程,并把所有 COM 调用调度到该线程。
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Threading;
public sealed class ComStaHost : IDisposable
{
private readonly Thread _thread;
private readonly TaskCompletionSource<Dispatcher> _ready =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private Dispatcher? _dispatcher;
private ComApplication? _app;
private bool _disposed;
public ComStaHost()
{
_thread = new Thread(ThreadMain)
{
IsBackground = true,
Name = "COM STA Thread"
};
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
}
private void ThreadMain()
{
_dispatcher = Dispatcher.CurrentDispatcher;
_ready.SetResult(_dispatcher);
Dispatcher.Run();
}
private async Task<T> InvokeAsync<T>(Func<T> operation)
{
if (_disposed)
throw new ObjectDisposedException(nameof(ComStaHost));
Dispatcher dispatcher = await _ready.Task.ConfigureAwait(false);
return await dispatcher.InvokeAsync(operation).Task.ConfigureAwait(false);
}
public Task<bool> ConnectAsync()
{
return InvokeAsync(() =>
{
_app = new ComApplication();
if (_app is null)
return false;
_app.Visible = true;
return true;
});
}
public Task<T> ExecuteAsync<T>(Func<ComApplication, T> operation)
{
ArgumentNullException.ThrowIfNull(operation);
return InvokeAsync(() =>
{
if (_app is null)
throw new InvalidOperationException("COM application is not connected.");
return operation(_app);
});
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Dispatcher? dispatcher = _dispatcher;
if (dispatcher is not null)
{
dispatcher.Invoke(() =>
{
_app = null;
dispatcher.BeginInvokeShutdown(DispatcherPriority.Normal);
});
}
_thread.Join();
}
}
这段代码通过以下方式约束 COM 对象的使用:
- COM 对象只在专用 STA 线程上创建。
- 属性和方法调用始终在同一线程上执行。
Dispatcher.Run()提供消息循环,让依赖 Windows 消息或回调的 COM server 能够正常工作。- 其他代码无法取得原始 COM 引用,只能通过
ExecuteAsync发起调用。
如果厂商文档明确说明该 COM API 支持 MTA 或自由线程模型,应遵循厂商要求。文档不完整时,可以先按 STA 模型验证桌面自动化 COM server。
不要随意调用 Marshal.FinalReleaseComObject。如果还有其他 RCW 或并发调用,后续访问可能直接落到已经释放的原生对象上。确实需要显式释放时,应先停止全部调用,再在创建对象的 STA 线程上按照明确的生命周期顺序释放。
检查崩溃的是哪个进程
先打开最终用户电脑上的“事件查看器”,检查:
- Windows 日志 → 应用程序
Application Error.NET Runtime- Windows Error Reporting
记录以下信息:
- Faulting application name
- Faulting module name
- Exception code
- Fault offset
- 应用程序和模块版本
- 崩溃进程 ID
- COM server 是否为另一个 EXE
如果 COM server 是独立进程,它崩溃后,客户端通常会收到类似 RPC_E_SERVERFAULT 或 RPC_E_DISCONNECTED 的错误。如果转储显示 WPF 进程本身发生 0xC0000005,还要检查加载到客户端进程中的组件,包括:
- COM proxy/stub DLL
- in-process COM server
- 厂商提供的本机 DLL
- custom marshaler
- 加载到进程中的插件或安全软件模块
如果事件记录显示崩溃的是 COM application 的 EXE,就需要收集该进程的转储,不能只收集 WPF 客户端的转储。
收集完整转储
PDB 是否存在不会影响转储文件的大小。转储保存的是进程当时的内存和线程状态,PDB 则用于在分析时把地址解析为函数名、源文件和行号。
应优先获取 full user-mode dump。小型转储可能缺少分析 CLR heap、线程栈或已加载模块所需的数据。
可以使用 Windows Error Reporting 的 LocalDumps,也可以使用 ProcDump。以下是 ProcDump 示例,具体参数应以当前版本的帮助文档为准:
procdump.exe -accepteula -ma -e 1 -f "C0000005" MyApplication.exe C:\Dumps
如果 COM server 在另一个进程中,也要单独监控该进程。首次异常模式可能生成多份无关转储。如果数量过多,可以改为只在出现未处理异常或进程终止时捕获。
收集转储时还应保存:
- 实际部署的 EXE 和 DLL
- 同一次构建生成的 PDB
- ClickOnce 发布版本号
- 对应的源代码提交
- 最终用户机器上的 COM server 文件版本
- 事件查看器记录
- 进程位数和操作系统位数
不要用重新编译生成的 PDB 分析旧转储。即使源代码完全相同,只要重新生成了二进制文件,PDB 标识就可能不再匹配。
在 Visual Studio 中分析
打开 .dmp 后,选择同时包含托管代码和本机代码的调试方式。具体按钮名称取决于 Visual Studio 版本和转储类型,通常显示为 Managed、Native 或 Mixed。
在符号设置中加入:
- 保存精确 PDB 的本地目录
- Microsoft Symbol Servers
- 本地 symbol cache 目录
然后打开 Modules 窗口,逐项确认:
- 自己的程序集是否加载了正确的 PDB
coreclr.dll等运行时模块是否加载了 Microsoft symbols- 崩溃模块是否属于 COM 厂商
- 模块的加载路径和版本是否符合预期
- Symbol Load Information 中拒绝 PDB 的具体原因
“not all symbols have Source Link information”不代表符号加载失败。Source Link 根据 PDB 中的映射下载对应版本的源文件。内部项目如果保存了准确的源代码,并且可以在调试器中手动定位源文件,就不依赖 Source Link。
“not all symbols could be found”也未必表示存在问题。系统组件、NuGet 依赖和第三方本机 DLL 不一定公开 PDB。需要确认的是崩溃线程上的关键模块是否有符号。
使用 WinDbg 判断故障模块
在 WinDbg 中打开转储后,可以先执行:
.symfix
.sympath+ C:\Symbols\MyApplication
.reload /f
!analyze -v
.ecxr
kv
lm
这些命令依次用于配置公共符号服务器、添加应用程序符号路径、重新加载符号、分析异常、切换到异常上下文、显示本机调用栈以及列出模块。
检查具体模块时,可以执行:
lmvm ModuleName
在 .NET 8 进程中,如果 SOS 尚未加载,可以尝试:
.loadby sos coreclr
然后查看托管线程和调用栈:
!threads
!clrstack -a
不同版本的 WinDbg 和 SOS 支持的命令可能略有差异,应以当前调试器的 !help 输出为准。
分析时需要确认:
0xC0000005发生在哪个线程。- 该线程当时正在执行哪个模块。
- 异常地址对应读、写还是执行访问。
- 调用栈上最后一个有意义的厂商模块或 COM proxy/stub 是什么。
- 托管栈能否对应到
ComApplication构造或Visiblesetter。 - 是否有其他线程正在并发调用或释放同一个 COM 对象。
如果崩溃发生在没有公开符号的厂商 DLL 中,通常仍能得到类似以下信息:
VendorModule.dll+0x12345
把模块的准确版本、时间戳、异常偏移、调用栈和复现步骤交给厂商,厂商可以使用私有 PDB 继续定位。
核对位数、注册信息和组件版本
“相同 OS 和 architecture”不足以排除部署差异,还需要比较:
- WPF 客户端是 x86、x64 还是 AnyCPU
- COM server 是进程内还是进程外
InprocServer32或LocalServer32指向的实际路径- CLSID、ProgID、TypeLib 和 AppID
- COM server 及其 proxy/stub 的版本
- 厂商运行库、插件、补丁和依赖项
- 当前用户与管理员账户下的注册差异
- 授权状态、用户配置文件和启动目录
进程内 COM server 必须与客户端位数一致。32 位进程无法加载 64 位 in-process DLL,反过来也一样。进程外 COM server 可以跨位数通信,但仍可能依赖与客户端位数匹配的 proxy/stub。
把 AnyCPU 改成 x86只能作为一次排查实验,不能视为通用修复。如果厂商只安装了 64 位 in-process 组件,强制使用 x86 会产生新的不匹配。
正确认识 Embed Interop Types 和 Copy Local
Embed Interop Types = Yes 通常只会把代码实际使用的 COM 类型描述嵌入应用程序集,不会打包以下内容:
- COM server EXE 或 DLL
- COM 注册信息
- 厂商的原生依赖
- proxy/stub
- custom marshaler
- COM server 的运行环境
Copy Local = Yes 针对托管 interop assembly。启用嵌入类型后,如果编译结果不再引用该程序集,它可能不会出现在发布目录中,这不表示发布有问题。
如果厂商提供了受支持的 PIA、interop assembly、安装程序或明确的注册步骤,应优先采用厂商方案。不要只把 COM DLL 复制到 ClickOnce 目录,也不要在没有厂商说明的情况下自行注册其他版本的组件。
还应进行的对照实验
可以通过以下实验尽快缩小排查范围:
- 在最终用户电脑上运行一个最小 STA 控制台程序,只创建 COM 对象并设置
Visible。 - 暂时在 WPF UI 线程上创建和调用 COM 对象,观察故障是否消失。
- 禁用并发调用,保证同一时间只有一个 COM 请求。
- 使用全新的 Windows 用户配置文件测试,排除厂商应用的用户级配置或插件。
- 比较正常电脑和故障电脑上实际加载的厂商 DLL 路径、文件版本与哈希。
- 在安装了相同版本 COM application 的第三台电脑上运行同一个 ClickOnce 构建。
如果改用单一 STA 线程后故障消失,原因通常在线程 apartment、消息循环或并发访问方面。如果最小 STA 程序仍在同一个厂商模块中崩溃,则更可能与组件安装、版本、注册、原生依赖或厂商自身缺陷有关。
AccessViolationException 通常无法通过 try/catch 安全恢复。即使偶尔能捕获,进程中的原生状态也可能已经损坏。此时应保留转储、终止本次进程,并修正线程、生命周期、位数或原生组件方面的问题。
备注:内容仅供参考。
