PAYATHON 2026

IAccessible 子项数大于 0 时,IDispatch::Invoke 为何崩溃?

排障赵师傅

先修正 IID,再修正函数签名

iid_dispatch 的定义用错了 GUID:

// 错误:这是 IID_IEnumVARIANT
iid_dispatch: TGUID = '{00020404-0000-0000-C000-000000000046}';

// 正确:IID_IDispatch
iid_dispatch: TGUID = '{00020400-0000-0000-C000-000000000046}';

这两个 GUID 属于不同的接口,可在 Windows SDK 的 oaidl.idl 中核对。

这个错误会让 QueryInterface() 把 IAccessible 接口指针当作 IEnumVARIANT 返回。客户端按枚举接口的布局调用方法,服务端却按 IDispatch 的布局处理,导致参数解释和栈清理大小都出错。

这足以解释“进入了 Invoke(),但 iid 和结果指针都不正常”的现象。是否由 IEnumVARIANT::Clone() 触发,还需要调用栈或接口查询日志确认。此外,代码还有几处独立的 ABI 错误:get_accFocus() 缺少 self,部分输出参数多了一层指针,accLocation() 的子项参数传递方式也有误。这些都需要修正。

为什么有子项之后才暴露问题?

当 get_accChildCount() 返回大于零的数量时,客户端会继续获取子项。MSAA 提供了 AccessibleChildren() 等遍历入口,客户端或中间层也可能查询 IEnumVARIANT。

这时,错误的 QueryInterface() 会声称支持枚举接口:

QueryInterface(IID_IEnumVARIANT)
    → 与错误定义的 iid_dispatch 匹配
    → 返回 S_OK 和 IAccessible 接口指针

两个接口的 vtable 槽位从零开始编号:

槽位IDispatchIEnumVARIANT
0QueryInterfaceQueryInterface
1AddRefAddRef
2ReleaseRelease
3GetTypeInfoCountNext
4GetTypeInfoSkip
5GetIDsOfNamesReset
6InvokeClone

如果客户端调用槽位 6 的 Clone(),实际执行的就是 auto__idispatch__Invoke()。Clone() 除接口指针外只接收一个输出参数,Invoke() 则需要八个参数,因此此时读到的 iid、varresult 等值没有意义。

在 Win32 stdcall 下,被调用方还负责清理参数栈。即使函数体只返回 E_NOTIMPL,编译器按错误签名生成的返回代码仍可能破坏栈。其他枚举方法也会调用到错误的槽位。枚举接口的方法签名可参见 Microsoft 的 IEnumVARIANT 实现声明。

子项数量大于零,使客户端走到了这条后续访问路径。把数量改成零可以避开它,但接口实现中的错误仍然存在。

第一步:让 QueryInterface 只返回真正实现的接口

修正 iid_dispatch 后,如果尚未实现 IEnumVARIANT,查询该接口时应返回 E_NOINTERFACE,并清空输出指针。

沿用现有的 Win32 函数形式,查询逻辑可以写成:

function auto__iunknown__queryinterface(
  self: Pointer;
  const iid: TGUID;
  out obj
): Longint; stdcall;
begin
  Pointer(obj) := nil;
  Result := E_NOINTERFACE;

  if com__guidMatch(iid, iid_unknown) or
     com__guidMatch(iid, iid_dispatch) or
     com__guidMatch(iid, iid_accessible) then
  begin
    Pointer(obj) := self;
    auto__iunknown__addref(self);
    Result := S_OK;
  end;
end;

这里假设 self 指向布局正确的 IAccessible 接口。IAccessible 继承自 IDispatch,所以这三个接口可以使用同一个 vtable 前缀。根据 IAccessible 的继承关系,也不应通过拒绝真正的 IID_IDispatch 查询来绕过崩溃。

代码中的 AddRef() 必须配合有效的生命周期管理。原来的 AddRef() 和 Release() 分别固定返回 2、1,没有维护引用计数。接口查询成功以及返回接口指针时,都应遵守引用计数规则,参见 IUnknown。

以后如果实现 IEnumVARIANT,需要提供包含 Next、Skip、Reset、Clone 的对应 vtable,不能继续返回当前的 IAccessible 接口指针。

第二步:逐个修正参数的指针层级

手工构造 COM vtable 时,调用过程不使用函数名称。槽位、参数顺序、大小、传值方式和调用约定都必须与接口定义完全一致。

VARIANT* 不能声明成 out PComVar

C 的输出参数:

VARIANT *pvarState

可以对应 Pascal 的:

pvarState: PComVar

也可以对应:

out varState: TComVar

原代码采用的是:

out pvarState: PComVar

out 会再增加一层间接访问,相当于要求调用方传入 VARIANT**。调用方实际提供的是 VARIANT*,函数便会把 VARIANT 开头的数据误读为指针。

至少需要检查以下函数:

  • get_accState
  • get_accFocus
  • get_accSelection
  • accNavigate
  • accHitTest

如果统一使用显式指针,可以改成以下声明。这里沿用原代码的 TComVar、PComVar 类型,仅适用于 Win32:

function auto__get_accState(
  self: Pointer;
  vChild: TComVar;
  pvarState: PComVar
): Longint; stdcall;

function auto__get_accSelection(
  self: Pointer;
  pvarChildren: PComVar
): Longint; stdcall;

function auto__accNavigate(
  self: Pointer;
  navDir: Longint;
  varStart: TComVar;
  pvarEndUpAt: PComVar
): Longint; stdcall;

function auto__accHitTest(
  self: Pointer;
  xLeft, yTop: Longint;
  pvarChild: PComVar
): Longint; stdcall;

get_accFocus 必须包含 self

手工放入 vtable 的普通函数必须显式接收接口指针。原来的 get_accFocus() 漏掉了第一个参数,会把接口地址误当成输出参数。

当前对象及其子项都没有键盘焦点时,可以这样返回:

function auto__get_accFocus(
  self: Pointer;
  pvarChild: PComVar
): Longint; stdcall;
begin
  if pvarChild = nil then
  begin
    Result := E_INVALIDARG;
    Exit;
  end;

  FillChar(pvarChild^, SizeOf(TComVar), 0);
  pvarChild^.vt := VT_EMPTY;
  Result := S_OK;
end;

这里的 VT_EMPTY 表示当前对象及其子项都没有焦点;如果返回子项 ID,则使用 VT_I4。详见 get_accFocus()。

accLocation 的最后一个参数必须按值传递

accLocation() 的最后一个参数是按值传递的输入 VARIANT,应改为:

function auto__accLocation(
  self: Pointer;
  out pxLeft, pyTop, pcxWidth, pcyHeight: Longint;
  varChild: TComVar
): Longint; stdcall;

原来的 out varChild: PComVar 将它声明成了输出指针,与接口签名不兼容,在 Win32 下也会影响参数栈的清理。

第三步:保持 Invoke 的 REFIID 传递方式正确

正常调用 IDispatch::Invoke() 时,riid 必须是 IID_NULL。REFIID 表示对 GUID 的引用,应保留 const,不能仅为改变调试时观察到的值,就把 GUID 改成按值传递。详见 Invoke 的参数约定。

修复 IID 和 ABI 后,可以先用下面的桩函数排查调用路径:

function auto__idispatch__Invoke(
  self: Pointer;
  xdispid: Longint;
  const iid: TGUID;
  localeid: Longint;
  flags: Word;
  var Params;
  varresult: PComVar;
  excepinfo, argerr: Pointer
): Longint; stdcall;
begin
  if not com__guidMatch(iid, iid_null) then
  begin
    Result := DISP_E_UNKNOWNINTERFACE;
    Exit;
  end;

  if varresult <> nil then
  begin
    FillChar(varresult^, SizeOf(TComVar), 0);
    varresult^.vt := VT_EMPTY;
  end;

  Result := DISP_E_MEMBERNOTFOUND;
end;

这个桩函数用于诊断,尚不能提供完整的 Automation 访问。正式实现还需要处理成员名、DISPID、参数和属性读写,也可以借助类型信息及 DispInvoke()。

原代码中的这两行:

excepinfo := nil;
argerr := nil;

只会修改局部指针变量,不会写入调用者提供的存储区,也不能用于判断调用参数是否有效。pExcepInfo 和 puArgErr 可以为空,只需在对应错误条件下填写。

还需要检查的实现细节

get_accChildCount() 返回的数量必须与实际子项一致。当前代码报告十个子项,却让所有正数 ID 都返回 auto_list[1],也没有检查上限。应检查 vChild.vt 和子项 ID 范围,并返回对应对象。如果采用没有独立接口的简单子元素模型,get_accChild() 对有效子元素应返回 S_FALSE 和空接口,由父对象根据子项 ID 提供属性。参见 get_accChild()。

不提供类型信息时,GetTypeInfoCount() 应把数量设为 0 并返回 S_OK。“没有类型信息”是该方法可以正常表达的状态。参见 GetTypeInfoCount()。

自定义 TComVar 时,除了总大小,还需要核对字段偏移、对齐和传递方式。复用已有值之前,应按类型释放它持有的 BSTR、接口或数组;修改 vt 或调用 FillChar() 都不能代替资源清理。当前崩溃也不足以证明 OleVariant 本身不稳定。

BSTR 分配函数宜使用明确的字符指针类型:

function win____SysAllocString(
  x: PWideChar
): Pointer; stdcall;
  external 'oleaut32.dll' name 'SysAllocString';

function com__makebstr(const x: string): Pointer;
var
  a: WideString;
begin
  a := x;
  Result := win____SysAllocString(PWideChar(a));
end;

这个 Delphi 3 和 Delphi 7 示例应按 Win32 ABI 核对。Comp 不能代替 Win64 的指针大小整数类型;以后迁移时,指针、句柄、WPARAM、LPARAM、HRESULT 和 LONG 必须分别按目标平台定义。

如何验证修复

先记录 QueryInterface() 收到的实际 GUID,并在首次异常处中断,检查调用栈。然后依次验证:

  1. 确认真正的 IID_IDispatch 查询成功。
  2. 未实现枚举接口时,确认 IID_IEnumVARIANT 查询返回 E_NOINTERFACE。
  3. 逐个验证修正后的 IAccessible 方法,重点检查含 VARIANT 的参数。
  4. 将子项数量设为真实值,再执行客户端遍历。

如果仍然进入 Invoke() 且 iid 异常,应检查调用来源、vtable 槽位,以及此前是否已经发生内存破坏。具体触发顺序需要实际调用栈才能确定;IID 错误、缺失 self 和指针层级错误则可以直接从代码中确认并修复。

备注:内容仅供参考。