触及了汇编语言中"压栈方向"与"内存增长方向"最容易混淆的地方。
结论先行: 你是对的,压栈顺序是反向(从右往左)的,但正因为是反向压栈,所以第一个参数反而离栈顶(ESP)最近。
为了让你彻底理解,我们拆解一下 Call 发生时栈内部到底发生了什么:
1. 压栈的过程 (从右往左)
假设 VB6 执行 sum(a, b),在汇编层面,stdcall 约定会按照代码中参数从右到左的顺序进行 PUSH:
-
PUSH [Sum_result 地址]:这是隐藏的最后一个参数(用于接收返回值)。
-
PUSH [b 的地址] :这是
ByRef b。 -
PUSH [a 的值] :这是
ByVal a。 -
PUSH [vb6_class_this] :这是隐藏的第一个参数(
this指针)。 -
CALL [函数地址] :执行
CALL的瞬间,CPU 会自动把返回地址压入栈顶。
2. 内存布局 (执行时的状态)
由于栈在 x86 架构中是向低地址增长 的(即越晚 PUSH 的数据,内存地址越小),所以当函数代码的第一行开始执行时,栈顶指针 ESP 指向的是最后进入的"返回地址"。
我们来看内存中的排列(假设当前 ESP 是 0x1000):
| 内存地址 | 偏移量 | 存储内容 | 压栈顺序 |
|---|---|---|---|
| 0x1000 | [ESP] |
返回地址 | 最后进入 (CALL 指令产生) |
| 0x1004 | [ESP + 4] |
vb6_class_this | 倒数第二次进入 (第一个参数) |
| 0x1008 | [ESP + 8] |
a | 倒数第三次进入 (第二个参数) |
| 0x100C | [ESP + 12] |
ptr b | ... |
| 0x1010 | [ESP + 16] |
ptr Sum_result | 最早进入 (最后一个参数) |
总结口诀: 越是函数签名里靠左(前面)的参数,越是晚压栈,也就离栈顶越近。
3. 修改跳板逻辑的验证
因为我们要修改的是第一个参数 vb6_class_this,而它就在 [ESP + 4] 的位置。所以我们的汇编指令:
MOV DWORD PTR [ESP + 4], csharpClassPtr
是精准命中目标的。
4. 关于你提到的"无返回值"
在 VB6 里,你定义的是 Function,但在底层,VB6 期望这个函数符合 COM 标准:
-
真正的返回值 是通过
EAX寄存器返回的HRESULT(0代表成功)。 -
代码里的返回值 是通过栈上最后一个参数
ptr Sum_result指向的内存写入的。
如果你的 C# 函数定义为 void,可能会导致 EAX 寄存器残留随机值,VB6 可能会误认为收到了一个错误代码(非 0),从而抛出"运行时错误"。
建议: C# 侧一定要返回 0 (int),哪怕你不需要计算结果。
C#
// C# AOT 侧
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvStdcall) })]
public static int MyCsharpSum(IntPtr csharpClassPtr, int a, IntPtr b, IntPtr result) {
// 逻辑...
return 0; // 必须返回 0 (S_OK),否则 VB6 会报异常
}
下一步:
既然你是在做虚拟类挂载,是否需要我帮你写一个支持动态 Args 数量的跳板生成器?这样你只需要传入参数个数,它就能自动计算出 ptr Sum_result 在栈上的偏移位置。