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 槽位从零开始编号:
| 槽位 | IDispatch | IEnumVARIANT |
|---|---|---|
| 0 | QueryInterface | QueryInterface |
| 1 | AddRef | AddRef |
| 2 | Release | Release |
| 3 | GetTypeInfoCount | Next |
| 4 | GetTypeInfo | Skip |
| 5 | GetIDsOfNames | Reset |
| 6 | Invoke | Clone |
如果客户端调用槽位 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_accStateget_accFocusget_accSelectionaccNavigateaccHitTest
如果统一使用显式指针,可以改成以下声明。这里沿用原代码的 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,并在首次异常处中断,检查调用栈。然后依次验证:
- 确认真正的
IID_IDispatch查询成功。 - 未实现枚举接口时,确认
IID_IEnumVARIANT查询返回E_NOINTERFACE。 - 逐个验证修正后的
IAccessible方法,重点检查含VARIANT的参数。 - 将子项数量设为真实值,再执行客户端遍历。
如果仍然进入 Invoke() 且 iid 异常,应检查调用来源、vtable 槽位,以及此前是否已经发生内存破坏。具体触发顺序需要实际调用栈才能确定;IID 错误、缺失 self 和指针层级错误则可以直接从代码中确认并修复。
备注:内容仅供参考。
