PAYATHON 2026

如何调试部署后由 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 尚未被调用。

建议按以下顺序排查:

  1. 确认崩溃的是 WPF 客户端还是独立的 COM server 进程。
  2. 将所有 COM 操作移到一个带消息循环的专用 STA 线程,禁止其他线程直接访问该对象。
  3. 核对客户端、COM server、proxy/stub 和注册信息的位数与版本。
  4. 收集完整用户模式转储,同时启用托管和原生调试。
  5. 使用与部署版本完全匹配的 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 输出为准。

分析时需要确认:

  1. 0xC0000005 发生在哪个线程。
  2. 该线程当时正在执行哪个模块。
  3. 异常地址对应读、写还是执行访问。
  4. 调用栈上最后一个有意义的厂商模块或 COM proxy/stub 是什么。
  5. 托管栈能否对应到 ComApplication 构造或 Visible setter。
  6. 是否有其他线程正在并发调用或释放同一个 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 安全恢复。即使偶尔能捕获,进程中的原生状态也可能已经损坏。此时应保留转储、终止本次进程,并修正线程、生命周期、位数或原生组件方面的问题。

备注:内容仅供参考。