VBAStack - Read the callstack in VBA6 and 7 (2 Viewers)

Ariche2

New member
Local time
Today, 23:33
Joined
Jan 31, 2026
Messages
7
Posted this on a few different forums, but Access is my home so figured I should post it here, too.

I've been working on a project called VBAStack, which was initially a project that used Microsoft's published symbols for VBE7.dll to call internal functions and read the callstack. Recently however, I found a way to do it entirely without that, and from there, then realised I could actually do it within VBA itself!

I did this by following some internal structures within VBE7.dll / VBA6.dll. The structures themselves are undocumented but a few communities online have figured out most of the fields within the structures. Figured out the rest myself, though.

The general flow goes like this (offsets are for x86, but the code works for both x86/x64);

VBA.Err --> Offset 0x18 of VBAErr, global EbThread address --> global EbThread address + 0xC, global "ExFrameTOS" address (top of the callstack) --> Offset 0x0 of each ExFrame = pointer to next ExFrame in the stack

For each ExFrame;

ExFrame --> Offset 0xC of ExFrame, pointer to "RTMI" (runtime method information) structure --> Offset 0x0 of RTMI, pointer to "ObjectInfo" structure (information about the "Object" the method belongs to - module, class, whatever)

ObjectInfo then leads to a couple of things. Offset 0x24 is a pointer to an array of pointers to more RTMI structs. 0x18 is a pointer to the "Public Object Descriptor", which itself has 2 more interesting pointers - at 0x18 it has a pointer to an ANSI null-terminated string, which is the object name (the name of your class or module). At 0x1C it has a pointer to an array of ANSI null term strings which are the names of the methods.

So for the method name, you find your RTMI pointer in the array of RTMI's on ObjectInfo 0x24 - once you find that, make a note of which element of the array it was. The array of method names on the "Public Object Descriptor" is in the same order, so you can find the name pretty easily. For example if your RTMI was the 2nd element in ObjectInfo's array of RTMIs, your methods name will be the 2nd element in the the "Public Object Descriptor"'s array of names.

99% of the credit for the structure reverse engineering goes to docs I found at the amazing SandSprite.com website - particularly the document there from Alex Ionescu.

See working code below (it assumes it's in a module called "VBAStack").

Note that this code is incredibly rough in all honesty - it could do with adding some more Win32 API calls to check that the addresses it reads with RtlMoveMemory are actually readable, otherwise if something goes wrong it will cause CTD's.

Example usage;
Code:
Private Sub Example()
    Dim Frames() As VBAStack.StackFrame
    Frames = VBAStack.GetCallstack()

    Dim str As String
    Dim i As Integer

    For i = 0 To UBound(Frames)
        str = str & Frames(i).ProjectName & "." & Frames(i).ObjectName & "." & Frames(i).ProcedureName & vbCrLf
    Next
    MsgBox (str)

    ' MyMod.Example
    ' MyMod.Sub2
    ' Form_Form1.Command0_Click
End Sub

Code:
Option Explicit

'Tested on x86 Access 2003, x86 Access 2013, x86 Access 365, x64 Access 2013, and x64 Access 365.

#If VBA7 = False Then
    Private Enum LongPtr
        [_]
    End Enum
    Private Declare Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (ByRef lpDest As Any, ByVal lpSource As LongPtr, ByVal cbCopy As Long)
#Else
    Private Declare PtrSafe Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (ByRef lpDest As Any, ByVal lpSource As LongPtr, ByVal cbCopy As Long)
#End If

#If Win64 Then
    Const PtrSize As Integer = 8
#Else
    Const PtrSize As Integer = 4
#End If

Public Type StackFrame
    ProjectName As String
    ObjectName As String
    ProcedureName As String
    realFrameNumber As Integer
    FrameNumber As Integer
    Errored As Boolean
End Type

Public Function FrameCount() As Integer

On Error GoTo ErrorOccurred

    FrameCount = -1

    'Get VBA.Err ptr
    Dim errObj As LongPtr
    errObj = ObjPtr(VBA.Err)

    'Get g_ebThread
    Dim g_ebThread As LongPtr
    CopyMemory g_ebThread, (errObj + PtrSize * 6), PtrSize
    If g_ebThread = 0 Then GoTo ErrorOccurred

    'Get g_ExFrameTOS
    Dim g_ExFrameTOS As LongPtr
    #If Win64 Then
        g_ExFrameTOS = g_ebThread + (&H10)
    #Else
        g_ExFrameTOS = g_ebThread + (&HC)
    #End If
    If g_ExFrameTOS = 0 Then GoTo ErrorOccurred

    'Get top ExFrame
    Dim pTopExFrame As LongPtr
    CopyMemory pTopExFrame, g_ExFrameTOS, PtrSize
    If pTopExFrame = 0 Then GoTo ErrorOccurred

    'Loop over frames to count
    Dim pExFrame As LongPtr: pExFrame = pTopExFrame
    Do
        CopyMemory pExFrame, pExFrame, PtrSize
        FrameCount = FrameCount + 1
        If pExFrame = 0 Then Exit Do
    Loop

Exit Function

ErrorOccurred:

End Function

Public Function GetCurrentProcedure() As StackFrame
    GetCurrentProcedure = VBAStack.GetStackFrame(2)
End Function

Public Function GetCallstack() As StackFrame()
    Dim stackCount As Integer: stackCount = VBAStack.FrameCount
    Dim index As Integer: index = 1
    Dim FrameArray() As StackFrame
    ReDim FrameArray(stackCount - 2)

    Do Until index = stackCount
        FrameArray(index - 1) = VBAStack.GetStackFrame(index + 1)
        index = index + 1
    Loop

    GetCallstack = FrameArray
End Function

Public Function GetStackFrame(Optional ByVal FrameNumber As Integer = 1) As StackFrame

On Error GoTo ErrorOccurred

    If FrameNumber < 1 Then GoTo ErrorOccurred

    Dim retVal As StackFrame
    retVal.realFrameNumber = FrameNumber
    retVal.FrameNumber = FrameNumber - 1

    'Get ptr to VBA.Err
    Dim errObj As LongPtr
    errObj = ObjPtr(VBA.Err)

    'Get g_ebThread
    Dim g_ebThread As LongPtr
    CopyMemory g_ebThread, (errObj + PtrSize * 6), PtrSize
    If g_ebThread = 0 Then GoTo ErrorOccurred

    'Get g_ExFrameTOS
    Dim g_ExFrameTOS As LongPtr
    #If Win64 Then
        g_ExFrameTOS = g_ebThread + (&H10)
    #Else
        g_ExFrameTOS = g_ebThread + (&HC)
    #End If
    If g_ExFrameTOS = 0 Then GoTo ErrorOccurred

    'Get top ExFrame
    Dim pTopExFrame As LongPtr
    CopyMemory pTopExFrame, g_ExFrameTOS, PtrSize
    If pTopExFrame = 0 Then GoTo ErrorOccurred

    'Get next ExFrame (minimum once as top frame is this procedure)
    Dim pExFrame As LongPtr: pExFrame = pTopExFrame
    Do
        CopyMemory pExFrame, pExFrame, PtrSize
        If pExFrame = 0 Then GoTo ErrorOccurred
        FrameNumber = FrameNumber - 1
    Loop Until FrameNumber = 0

    'Get RTMI
    Dim pRTMI As LongPtr
    CopyMemory pRTMI, (pExFrame + PtrSize * 3), PtrSize
    If pRTMI = 0 Then GoTo ErrorOccurred

    'Get ObjectInfo
    Dim pObjectInfo As LongPtr
    CopyMemory pObjectInfo, pRTMI, PtrSize
    If pObjectInfo = 0 Then GoTo ErrorOccurred

    'Get Public Object Descriptor
    Dim pPublicObject As LongPtr
    CopyMemory pPublicObject, (pObjectInfo + PtrSize * 6), PtrSize
    If pPublicObject = 0 Then GoTo ErrorOccurred

    'Get module name pointer from Public Object Descriptor
    Dim pObjectName As LongPtr
    CopyMemory pObjectName, (pPublicObject + PtrSize * 6), PtrSize
    If pObjectName = 0 Then GoTo ErrorOccurred

    'Read object name
    Dim objName As String
    Dim readByteObjName As Byte
    Do
        CopyMemory readByteObjName, pObjectName, 1
        pObjectName = pObjectName + 1
        If readByteObjName = 0 Then Exit Do
        objName = objName & Chr(readByteObjName)
    Loop
    retVal.ObjectName = objName

    'Get method array from ObjectInfo
    Dim pMethodsArr As LongPtr
    CopyMemory pMethodsArr, (pObjectInfo + PtrSize * 9), PtrSize
    If pMethodsArr = 0 Then GoTo ErrorOccurred

    'Get method count from Public Object Descriptor
    Dim methodCount As Long
    CopyMemory methodCount, (pPublicObject + PtrSize * 7), 4
    If methodCount = 0 Then GoTo ErrorOccurred

    'Search method array to find our RTMI
    Dim methodIndex As Integer: methodIndex = -1
    Dim i As Integer
    Dim pMethodRTMI As LongPtr
    For i = methodCount - 1 To 0 Step -1
        CopyMemory pMethodRTMI, (pMethodsArr + PtrSize * i), PtrSize
        If pMethodRTMI = 0 Then GoTo ErrorOccurred
        If pMethodRTMI = pRTMI Then
            methodIndex = i
            Exit For
        End If
    Next

    If methodIndex = -1 Then GoTo ErrorOccurred

    'Get proc name array from Public Object Descriptor
    Dim pMethodNamesArr As LongPtr
    CopyMemory pMethodNamesArr, (pPublicObject + PtrSize * 8), PtrSize
    If pMethodNamesArr = 0 Then GoTo ErrorOccurred

    'Get pointer to proc name
    Dim pMethodName As LongPtr
    CopyMemory pMethodName, (pMethodNamesArr + PtrSize * methodIndex), PtrSize
    If pMethodName = 0 Then GoTo ErrorOccurred

    'Read proc name
    Dim procName As String
    Dim readByteProcName As Byte
    Do
        CopyMemory readByteProcName, pMethodName, 1
        pMethodName = pMethodName + 1
        If readByteProcName = 0 Then Exit Do
        procName = procName & Chr(readByteProcName)
    Loop
    retVal.ProcedureName = procName

    'Get ObjectTable
    Dim pObjectTable As LongPtr
    CopyMemory pObjectTable, (pObjectInfo + PtrSize * 1), PtrSize
    If pObjectTable = 0 Then GoTo ErrorOccurred

    'Get proj name from ObjectTable
    Dim pProjName As LongPtr
    #If Win64 Then
        CopyMemory pProjName, (pObjectTable + &H68), PtrSize
    #Else
        CopyMemory pProjName, (pObjectTable + &H40), PtrSize
    #End If
    If pProjName = 0 Then GoTo ErrorOccurred

    'Read project name
    Dim projName As String
    Dim readByteProjName As Byte
    Do
        CopyMemory readByteProjName, pProjName, 1
        pProjName = pProjName + 1
        If readByteProjName = 0 Then Exit Do
        projName = projName & Chr(readByteProjName)
    Loop

    retVal.ProjectName = projName
    GetStackFrame = retVal

Exit Function

ErrorOccurred:
    retVal.Errored = True
    GetStackFrame = retVal
End Function
 
Last edited:
Impressive.
I set a breakpoint in NW2-Dev.modInventory.ProductAvailable and then added a product to an order.
The call stack showed:
1769876055918.png

and your code showed:
1769876071424.png

(testDumpStack is my code that calls your code).
There was no crash.
A365-32 MEC.

I will leave it to others to comment on the legalities of RE tools like Ghidra in the context of MSFT EULA. I would remove that part from your post.
 
Very nice. Works on 64-bit VBA well too! (y)
 
Impressive.
I set a breakpoint in NW2-Dev.modInventory.ProductAvailable and then added a product to an order.
The call stack showed:
View attachment 123017
and your code showed:
View attachment 123018
(testDumpStack is my code that calls your code).
There was no crash.
A365-32 MEC.

I will leave it to others to comment on the legalities of RE tools like Ghidra in the context of MSFT EULA. I would remove that part from your post.
Huh - that "ImmedProc" part I believe to be related to the immediate window? I didn't know it had an object name or procedure name though - I honestly thought it would just break my code entirely. Interesting!

I wouldve thought if you used the immediate window to run the code though, it would be at the bottom of the stack - not in the middle somewhere? So that's odd. Possibly related to the breakpoint..?
 
Huh - that "ImmedProc" part I believe to be related to the immediate window? I didn't know it had an object name or procedure name though - I honestly thought it would just break my code entirely. Interesting!

I wouldve thought if you used the immediate window to run the code though, it would be at the bottom of the stack - not in the middle somewhere? So that's odd. Possibly related to the breakpoint..?
I did not use the Immediate window.
 
I did not use the Immediate window.
Well that's even more interesting. I'm guessing it must be related to the breakpoint then..? But I can't seem to replicate it myself. Odd!

Edit: Yeah, if I call something from the immediate window I do get that frame in the result. Running "Form_Form1.Command0_Click" in the immediate window gives me this:

Screenshot 2026-01-31 191057.png


Still no clue what triggered it in your example though.
 
Like Tom, I get nervous when discussing things that might be taken as an attempt to reverse-engineer Access. That action is expressly forbidden by the End User Licensing Agreement. Since Access is NOT an Open Source product, we can only assume that MSFT would prefer to NOT have its internals spread out for display after dissection. I'm not going to use my moderator abilities at the moment to censor things, but I invite other members to comment on whether they feel this too closely impinges on the EULA. And TRUST me, we DON'T need that kind of trouble. We already are treading on eggshells after a few issues with some unruly users.

@Ariche2, PLEASE understand that we are not picking on you or anything like that. We simple need to protect the forum from unwanted legal issues that could pop up if this actually crosses the line. To paraphrase Michael Corleone from nearly any of the Godfather movies, "It isn't personal - it is strictly business."
 
Like Tom, I get nervous when discussing things that might be taken as an attempt to reverse-engineer Access. That action is expressly forbidden by the End User Licensing Agreement. Since Access is NOT an Open Source product, we can only assume that MSFT would prefer to NOT have its internals spread out for display after dissection. I'm not going to use my moderator abilities at the moment to censor things, but I invite other members to comment on whether they feel this too closely impinges on the EULA. And TRUST me, we DON'T need that kind of trouble. We already are treading on eggshells after a few issues with some unruly users.

@Ariche2, PLEASE understand that we are not picking on you or anything like that. We simple need to protect the forum from unwanted legal issues that could pop up if this actually crosses the line. To paraphrase Michael Corleone from nearly any of the Godfather movies, "It isn't personal - it is strictly business."
Understood - have made changes myself. We OK with the mentions of other communities / figures that do more openly take things apart? I.e., Sandsprite and Ionescu.
 
I misspoke. Once at the breakpoint, I *did* use the Immediate window to call my testDumpStack procedure . Sorry for the confusion.
Ahh, that makes more sense. Interesting that it leaves the rest of the callstack intact even if you manually intervene! Thanks for the clarification.
 
Quite interesting and impressive. I don't see any reason to be concerned ... you're using things that are available to you.
 
Quite interesting and impressive. I don't see any reason to be concerned ... you're using things that are available to you.

That's why I did NOT intervene to censor anything. I wasn't sure so I solicited opinions - and you provided one.
 
I had to modify the initial Option Explicit On to just Option Explicit and changed the whole CopyMemory API definition for this to work. I had the immediate window line appear in the result too but I did not use it at all. If anything, it was just visible.
 
The line .0_#ImmMod#_._ImmedProc is nothing to do with the immediate window being used.

It is caused by a simple error in your Example sub - you forgot that Frames is zero based
To get rid of that unwanted line, your loop should be: For i = 0 To UBound(Frames) -1

Example output:

1769956867994-png.123029
 

Attachments

  • 1769956867994.png
    1769956867994.png
    34.8 KB · Views: 33
Last edited:
I had to modify the initial Option Explicit On to just Option Explicit and changed the whole CopyMemory API definition for this to work. I had the immediate window line appear in the result too but I did not use it at all. If anything, it was just visible.
Hm. What was wrong with the CopyMemory definition?
 
The line .0_#ImmMod#_._ImmedProc is nothing to do with the immediate window being used.

It is caused by a simple error in your Example sub - you forgot that Frames is zero based
To get rid of that unwanted line, your loop should be: For i = 0 To UBound(Frames) -1

Example output:

1769956867994-png.123029
I don't think so Colin.
You are free to ignore a stack frame in YOUR code, but the Example is not wrong. It loops from 0 to Ubound, which comprises of all array elements / stack frames.
Maybe you were thinking of:
For i = 0 To FrameCount() - 1
 
Hm. What was wrong with the CopyMemory definition?
My experience was that the Private Enum LongPtr definition appeared in Red. I did not even try to compile it, but I just did and to my surprise it did work. I don't have #VBA6 available here (it's now 20 years later; such dinosaurs will likely not be using your code :-)) so I cannot test in that environment.
1769965739057.png
 
You're right. My previous suggestion only worked when Example was called from another procedure.

I tried a different approach to strip out the unwanted line(s) which works whether the Example procedure is called from:
  • the immediate window
  • as part of another procedure which has been stopped
The screenshot below is when run from the immediate window:

1769969058105.png


Notice that the Immediate window appears twice using that approach

Next run from the TestCallStack procedure:

1769969261282.png
 
My experience was that the Private Enum LongPtr definition appeared in Red. I did not even try to compile it, but I just did and to my surprise it did work. I don't have #VBA6 available here (it's now 20 years later; such dinosaurs will likely not be using your code :-)) so I cannot test in that environment.
View attachment 123030
Oh - yeah. That's just a trick to make LongPtr "work" in VBA6 (so long as it's a 4 byte value, it's fine) so I don't have to duplicate tonnes of code. It isn't valid in VBA7 but that doesn't matter, since the conditional compilation ignores it. You get the same with any stuff declared as PtrSafe in VBA6.

As for dinosaurs - trust me I wish I could ignore VBA6, but for some ungodly reason I haven't been able to pry Access 03 out of some of my users hands just yet. Has been interesting seeing how little these internal structures have changed over the years though! I'm about 99% sure this code would work in VB6 without much fussing.
 

Users who are viewing this thread

Back
Top Bottom