Access开发:AddressOf、函数指针与 VBA 回调的运行边界

3.png

Hi,大家好!

今天我们讨论一下API。也就是我们通常只关心“我调用了什么,得到了什么结果”。

调用 Windows API 时,我们通常站在调用方的位置:准备参数,调用函数,拿回结果。

AddressOf 把这件事翻了过来。

你把一个 VBA 过程交给 Windows,之后由 Windows 决定什么时候调用它、给它什么参数,以及拿它的返回值做什么。

这也是回调代码难写的原因。普通过程之间传错参数,VBA 往往能给出类型不匹配;跨过原生代码边界以后,错误的声明可能直接破坏调用现场,连正常的错误提示都来不及出现。

我想从一个具体实验讲起:让 Windows 枚举顶层窗口,每找到一个窗口,就调用一次我们写的 VBA 函数。

重点不在统计窗口,而在理解:外部代码凭什么能调用 VBA,我们又必须保证什么?

先分清:执行函数,和交出函数地址

平常写 result = SomeFunction(),意思是现在执行这个函数,把返回值赋给变量。

下面的代码含义不同:

apiResult = EnumWindows(AddressOf WindowCountCallback, 0)

这里不会先执行 WindowCountCallback,再把结果传给 EnumWindows。传过去的是可供原生代码调用的过程入口地址。

严格说,AddressOf 是运算符,不是返回业务结果的普通函数。可以把它理解成 VBA 向原生调用方提供的入口,但不要进一步推断它一定就是某段 VBA 源码对应的裸机器码地址;内部如何桥接,由运行时负责。

执行关系可以写成:

VBA 调用 EnumWindows    Windows 找到一个顶层窗口
    Windows 调用 WindowCountCallback
    VBA 回调返回是否继续
    Windows 找到下一个窗口
    ……
EnumWindows 返回
VBA 继续执行下一句

EnumWindows 在这次调用中完成枚举,属于同步回调。控制权来回切换,不代表出现了多个并行执行 VBA 的线程。

还有一个限制:AddressOf 指向的过程必须位于调用所在项目的标准模块中。不能把窗体模块或普通类模块的实例方法直接交过去。实例方法还涉及对象身份;一个普通的回调地址不会自动携带你的 Me。

VBA 也没有供你把任意数值地址当成普通函数直接执行的语言语法。它不是一套通用的委托机制。

1.png

地址只是入口,签名才决定能否正确调用

Windows 文档给出的回调原型是:

BOOL CALLBACK EnumWindowsProc(HWND hwnd, LPARAM lParam);

这行定义比回调函数叫什么更重要。它约定了两个参数的顺序、宽度和传递方式,以及返回值的类型。

对应到现代 Windows Office 的 VBA,应当写成:

Public Function WindowCountCallback( _
    ByVal windowHandle As LongPtr, _
    ByVal contextValue As LongPtr) As Long

这里有三个容易混淆的地方。

HWND 是窗口句柄,LPARAM 是指针宽度的整数,因此使用 LongPtr。LongPtr 在 32 位 Office 中对应 Long,在 64 位 Office 中对应 LongLong。判断依据是运行 VBA 的 Office 位数,不是只看 Windows 位数。

返回值 BOOL 则始终是 32 位整数,因此使用 Long,不能因为运行在 64 位 Office 就改成 LongPtr,也不能因为名字里有 Boolean 的意思就写成 VBA 的 Boolean。VBA 的 Boolean 占 2 字节,Windows 的 BOOL 占 4 字节。

两个参数还必须明确写 ByVal。VBA 默认按引用传参,如果照默认写法声明,VBA 可能把 Windows 传入的句柄值当成可供间接访问的地址。句柄与“保存句柄的变量地址”不是一回事。

PtrSafe 也不会替你检查这些错误。它表明声明面向支持指针安全声明的环境,不是编译器对整份 API 签名出具的正确性证明。

再往下一层,是调用约定。在 32 位 Windows 中,适用于 VBA AddressOf 的回调原型需要使用 __stdcall,这关系到参数传递与栈清理;不能拿需要 __cdecl 的原型随意替换。64 位 Windows 使用统一的 x64 调用约定,但参数类型、宽度、顺序仍然必须匹配。

这些属于二进制调用接口,也就是 ABI。API 文档里的类型不是可有可无的注释。

写一个能停止、能报告错误的枚举实验

下面的代码适用于 Windows 上的 VBA7,也就是 Office 2010 及之后的 VBA 环境。声明兼顾 32 位与 64 位 Office;不适用于 Mac Office,也不包含旧版 VBA6 的兼容分支。

先保存正在编辑的文件,在测试用数据库中打开 VBA 编辑器,插入一个标准模块,命名为 modWindowCallbackDemo,放入下面的完整代码。模块名不要与函数名相同。

Option Explicit

Private Declare PtrSafe Function EnumWindows Lib "user32" ( _
    ByVal callbackAddress As LongPtr, _
    ByVal contextValue As LongPtr) As Long

Private Declare PtrSafe Sub SetLastError Lib "kernel32" ( _
    ByVal errorCode As Long)

Private m_WindowCount As Long
Private m_IsEnumerating As BooleanPrivate m_StoppedByLimit As BooleanPrivate m_CallbackErrorNumber As Long
Private m_CallbackErrorDescription As String

Public Function CountTopLevelWindows( _
    Optional ByVal maxCount As Long = 0) As Long

    Dim apiResult As Long
    Dim apiError As Long
    Dim savedNumber As Long
    Dim savedSource As String
    Dim savedDescription As String

    If m_IsEnumerating Then
        Err.Raise vbObjectError + 2048, _
            "CountTopLevelWindows", "Enumeration is already running."    End If

    If maxCount < 0 Then
        Err.Raise 5, "CountTopLevelWindows", _
            "maxCount must be zero or positive."    End If    On Error GoTo Failed

    m_IsEnumerating = True
    m_WindowCount = 0
    m_StoppedByLimit = False
    m_CallbackErrorNumber = 0
    m_CallbackErrorDescription = vbNullString

    SetLastError 0
    apiResult = EnumWindows( _
        AddressOf WindowCountCallback, CLngPtr(maxCount))
    apiError = Err.LastDllError

    If m_CallbackErrorNumber <> 0 Then
        Err.Raise m_CallbackErrorNumber, _
            "CountTopLevelWindows", m_CallbackErrorDescription    End If

    If apiResult = 0 And Not m_StoppedByLimit Then
        Err.Raise vbObjectError + 2049, "CountTopLevelWindows", _
            "EnumWindows failed. Win32 error: " & CStr(apiError)    End If

    CountTopLevelWindows = m_WindowCount
    m_IsEnumerating = False
    Exit FunctionFailed:
    savedNumber = Err.Number
    savedSource = Err.Source
    savedDescription = Err.Description
    m_IsEnumerating = False
    Err.Raise savedNumber, savedSource, savedDescriptionEnd FunctionPublic Function WindowCountCallback( _
    ByVal windowHandle As LongPtr, _
    ByVal contextValue As LongPtr) As Long    On Error GoTo Failed

    If Not m_IsEnumerating Then
        WindowCountCallback = 0
        Exit Function
    End If

    m_WindowCount = m_WindowCount + 1

    If contextValue > 0 Then
        If m_WindowCount >= contextValue Then
            m_StoppedByLimit = True
            WindowCountCallback = 0
            Exit Function
        End If    End If

    WindowCountCallback = 1
    Exit FunctionFailed:
    m_CallbackErrorNumber = Err.Number
    m_CallbackErrorDescription = Err.Description
    WindowCountCallback = 0End Function

在立即窗口中分别执行:

? CountTopLevelWindows()
? CountTopLevelWindows(3)
? CountTopLevelWindows(1)

第一句统计这次枚举遇到的顶层窗口,具体数量取决于当前桌面环境。它不是任务栏上的应用数量,也不保证这些窗口都可见。Windows 8 及之后,EnumWindows 枚举的是桌面应用的顶层窗口,不是整个系统里所有种类的窗口。

第二句最多统计 3 个窗口,第三句最多统计 1 个。只要枚举中存在相应数量的窗口,就会达到限制后停止。

回调接收到的 windowHandle 在本例中没有参与计算,但仍必须保留这个参数,并正确声明类型。调用方不会因为我们不用它就改变传参方式。

这些句柄是借来识别窗口的。本例没有创建窗口,不应该为它们调用 DestroyWindow,也不能把它们当作需要 CloseHandle 的通用内核句柄。

lParam 不只是“最后一个不知道怎么填的参数”

EnumWindows 的第二个参数会被原样传给每次回调。在本例中,它携带的是最大枚举数量。

所以回调不必猜调用方想做什么,它从 contextValue 读取这次调用的限制。这就是上下文参数的用途。

有些原生接口允许上下文参数携带结构体指针或其他状态地址,但“这个位置能放一个地址”与“任何对象地址都可以放心传进去”之间,差着内存布局和生命周期。

尤其不要把 ObjPtr 返回的数值直接当成 VBA 对象参数。数值地址不会自动建立正确的对象引用,也不会自动延长对象寿命。要做这种桥接,需要单独解决对象恢复、引用管理和失效时机,不适合靠一段来历不明的内存复制代码跳过。

这里用整数上下文,配合模块级计数,是为了让实验的状态关系清楚。代价是一次只能进行一个枚举,因此入口明确拒绝重入。

若在回调中再次调用同一个枚举函数,新的调用就可能覆盖旧调用的计数和错误状态。单线程代码也会发生嵌套调用;“没有多线程”不能证明共享状态一定安全。

返回 0,为什么不一定代表失败?

回调返回非零值,Windows 继续枚举;返回 0,Windows 停止枚举。

但停止枚举后,EnumWindows 自己也返回 0。于是同一个结果可能对应不同原因:达到限制、回调内部发生错误,或者 API 本身失败。

只写下面的判断,会把主动停止误报为失败:

If apiResult = 0 Then
    MsgBox "Failed"End If

完整示例用 m_StoppedByLimit 区分主动停止,用另外两个变量保存回调错误。只有排除这两种情况,才按 API 失败处理。

Err.Number 和 Err.LastDllError 也不是同一套错误信息。前者描述 VBA 错误,后者用于读取最近的原生 DLL 错误。DLL 错误值应紧跟在相关 API 调用后读取,并按照该 API 文档解释,不能隔着其他调用再拿来推断。

本例调用前先清零原生错误状态。主动停止和 VBA 回调错误由封装函数自己的标志解释,不依赖旧的 Win32 错误值。若要让其他原生调用方也能从 GetLastError 理解回调为什么停止,就需要另外定义错误码,并按文档在回调中设置它。

回调里发生错误,为什么不能直接向外抛?

普通 VBA 过程嵌套调用,外层可以用错误处理器接住内层错误。但在这里,回调的直接调用者是 Windows 原生代码,它不是一个正常的 VBA 业务调用层。

不能假定 VBA 错误可以沿着原生调用栈,安全地穿回最外层的 VBA 过程。

因此,示例在回调中做了三件事:保存错误,返回 0,让枚举停止。等 EnumWindows 正常交回控制权以后,再由外层 VBA 函数执行 Err.Raise。

错误先转成回调协议允许的结果,再在 VBA 一侧恢复成错误。

这里没有用一整段 On Error Resume Next 吞掉所有问题。忽略错误后继续返回 1,可能让调用方把不完整的结果当成成功结果。

可以在测试副本中,把回调里的计数语句临时替换为:

Err.Raise 5, "WindowCountCallback", "Injected callback error."

再运行 CountTopLevelWindows()。预期是回调截住错误、结束枚举,然后外层报告错误 5。恢复原语句后应当可以再次正常运行。

测试时不要改错函数签名来“看看会发生什么”。签名错误可能让宿主崩溃,不是普通的异常测试。原生访问违规也不在本例 VBA 错误处理的保证范围内。

2.png

同步回调能跑,不等于所有回调都能照搬

EnumWindows 的边界比较清楚:调用开始,发生若干次回调,调用结束。API 返回以后,这次枚举不会继续持有地址来调用我们的过程。

其他接口可能保存回调地址,在稍后的消息处理过程中调用;还有的接口可能从其他线程调用。两者都不能直接套用本例。

前一种情况必须明确注册、注销和停止通知的顺序。窗体已经关闭、相关状态已经释放,甚至 VBA 项目已经重置,外部代码仍然持有旧入口,后果就不再是“某个控件没找到”。

后一种情况还涉及线程和宿主运行环境。原生 API 能在工作线程回调,并不意味着 Office 的 VBA 运行时与窗体对象允许这样被调用。

在原生回调仍可能到达时,不要随意按 VBE 的重置按钮、执行 End 或改动项目代码。退出 VBA 的执行状态,不等于替外部接口完成了注销。

本例也没有在回调中加 DoEvents、弹出对话框或操作复杂界面。回调越短,状态变化越少,越容易判断何时可以退出。真要处理耗时业务,可以先收集需要的数据,在枚举返回后交给普通 VBA 过程处理。

写到这里,AddressOf 的难点已经不在“地址怎么传”了。

它要求我们同时弄清调用双方的签名、控制协议、错误协议与生命周期。少看其中一项,代码都有可能在演示中正常,在实际环境中失效。

我对这类代码的判断标准很具体:不只看能不能回调成功,还要看能不能主动停止、失败以后能不能恢复,以及外部调用结束后是否仍有地址被保留。把这些问题回答清楚,才算真正理解了这一段 VBA。

参考资料:Microsoft Learn 的 AddressOf 运算符、EnumWindows/EnumWindowsProc,以及 64 位 VBA 概述。阅读旧示例时,要额外核对其中的指针和句柄声明,不要直接沿用早期的 Long 写法。


企业管理系统定制

围绕采购、销售、库存、生产排程、人事考勤等实际业务,定制 ERP、MRP、WMS、OA、MES 等管理系统,也承接部门级应用和日常业务小工具开发。

Access 旧系统升级与 Web 迁移

从现有表结构、查询、窗体、报表和 VBA 入手,梳理值得保留的业务规则。按需重建 Web 界面、角色权限与移动端业务,通过数据核对和分阶段上线,延续原系统的业务积累。

EdonSoft Access 开发框架

登录、权限、菜单、代码生成、通用查询、Excel 导入和附件等基础功能已经准备好,生成的窗体和 VBA 代码可以继续修改。社区版可免费下载;专业版增加工作流审批、MySQL 后端、数据库升迁向导等功能。

了解框架与下载社区版

企业内训与项目技术支持

提供 Access 网络培训、企业内部培训、项目技术服务与技术支持,帮助希望自行开发和维护系统的个人与团队解决实际项目问题。