【问题标题】:Stack cleanup not working (__stdcall MASM function)堆栈清理不起作用(__stdcall MASM 函数)
【发布时间】:2021-06-29 10:04:45
【问题描述】:

这里发生了一些奇怪的事情。 Visual Studio 让我知道 ESP 值未正确保存,但我看不到代码中的任何错误(32 位、windows、__stdcall)

MASM 代码:

.MODE FLAT, STDCALL
...
    memcpy PROC dest : DWORD, source : DWORD, size : DWORD
    MOV EDI, [ESP+04H]
    MOV ESI, [ESP+08H]
    MOV ECX, [ESP+0CH]
    AGAIN_:
    LODSB
    STOSB
    LOOP AGAIN_
    RETN 0CH
    memcpy ENDP

我将 12 个字节 (0xC) 传递给堆栈,然后清理它。我通过查看符号确认了函数符号类似于“memcpy@12”,因此它确实找到了正确的符号

这是 C 原型:

extern void __stdcall * _memcpy(void*,void*,unsigned __int32);

以 32 位编译。该函数复制内存(我可以在调试器中看到),但堆栈清理似乎不起作用

编辑:

MASM 代码:

__MyMemcpy PROC _dest : DWORD, _source : DWORD, _size : DWORD
MOV EDI, DWORD PTR [ESP + 04H]
MOV ESI, DWORD PTR [ESP + 08H]
MOV ECX, DWORD PTR [ESP + 0CH]
PUSH ESI
PUSH EDI
__AGAIN:
LODSB
STOSB
LOOP __AGAIN
POP EDI
POP ESI
RETN 0CH
__MyMemcpy ENDP

C 代码:

extern void __stdcall __MyMemcpy(void*, void*, int);

typedef struct {
 void(__stdcall*MemCpy)(void*,void*,int);
}MemFunc;

int initmemfunc(MemFunc*f){
f->MemCpy=__MyMemcpy
}

当我这样称呼它时,我得到了错误:

MemFunc mf={0};
initmemfunc(&mf);
mf.MemCpy(dest,src,size);

当我这样称呼它时,我不会:

__MyMemcpy(dest,src,size)

【问题讨论】:

  • 它是一个 typedef 原型。我把它放在一个结构中,并在运行时用适当的 memcpy 函数填充它。实际的 extern 定义是相同的,但添加了 extern 而没有 typedef。就像我说的那样,该函数可以工作并复制内存,但堆栈是问题
  • memcpy 是自定义函数的错误名称。 CRT 中已经有一个memcpy
  • 我没有将任何库导入到我的项目中
  • leave 不需要以enter 开头。单独离开相当于mov esp, ebppop ebp,并且通常是两个单独指令的首选。在现实世界的情况下leave 可能与两条独立指令的速度相似,并且只占用一个字节。我不知道您为什么要使用 PROC,除非您不使用任何功能,除非在末尾添加 @12
  • @CarolVictor: 顺便说一句,在循环中使用lodsb / stosb 使用慢速loop 指令将比rep movsb很多(可以使用快速字符串微码,每个时钟周期实际复制 16 或 32 个字节)。

标签: c assembly x86 masm stdcall


【解决方案1】:

由于您提供了对问题的更新,并且 cmets 建议您禁用使用 MASM PROC 指令创建的函数的序言和尾声代码生成,我怀疑您的代码看起来像这样:

.MODEL FLAT, STDCALL
OPTION PROLOGUE:NONE
OPTION EPILOGUE:NONE

.CODE

__MyMemcpy PROC _dest : DWORD, _source : DWORD, _size : DWORD
    MOV EDI, DWORD PTR [ESP + 04H]
    MOV ESI, DWORD PTR [ESP + 08H]
    MOV ECX, DWORD PTR [ESP + 0CH]
    PUSH ESI
    PUSH EDI
__AGAIN:
    LODSB
    STOSB
    LOOP __AGAIN
    POP EDI
    POP ESI
    RETN 0CH
__MyMemcpy ENDP

END

关于此代码的注释:请注意,如果您的源缓冲区和目标缓冲区重叠,这可能会导致问题。如果缓冲区不重叠,那么您正在做的事情应该有效。您可以通过标记指针__restrict 来避免这种情况。 __restrict 是一个 MSVC/C++ 扩展,它将作为对编译器的提示,即参数不与另一个重叠。这可能允许编译器潜在地警告这种情况,因为您的汇编代码对于这种情况是不安全的。你的原型可以写成:

extern void __stdcall __MyMemcpy( void* __restrict, void* __restrict, int);

typedef struct {
    void(__stdcall* MemCpy)(void* __restrict, void* __restrict, int);
}MemFunc;

您正在使用PROC,但没有利用它提供(或隐藏)的任何潜在功能。您已使用 OPTION 指令禁用 PROLOGUE 和 EPILOGUE 生成。您正确地使用了RET 0Ch 从堆栈中清除了 12 个字节的参数。

从 STDCALL 调用约定的角度来看,您的代码是正确的,因为它与堆栈使用有关。存在一个严重的问题,即 Microsoft Windows STDCALL calling convention 要求调用者保留它使用的所有寄存器,除了 EAXECXEDX。您破坏了 EDIESI 并且两者都需要在使用之前保存。在您的代码中,您可以在它们的内容被破坏后保存它们。您必须先将 ESIEDI 都压入堆栈。这将要求您将相对于 ESP 的偏移量添加 8。你的代码应该是这样的:

__MyMemcpy PROC _dest : DWORD, _source : DWORD, _size : DWORD
    PUSH EDI                         ; Save registers first
    PUSH ESI
    MOV EDI, DWORD PTR [ESP + 0CH]   ; Arguments are offset by an additional 8 bytes
    MOV ESI, DWORD PTR [ESP + 10H]
    MOV ECX, DWORD PTR [ESP + 14H]
__AGAIN:
    LODSB
    STOSB
    LOOP __AGAIN
    POP ESI                          ; Restore the caller (non-volatile) registers
    POP EDI
    RETN 0CH
__MyMemcpy ENDP

您询问了为什么您似乎收到有关 ESP 或堆栈问题的错误。我假设您收到类似于此的错误:

这可能是由于在混合 STDCALL 和 CDECL 调用约定时 ESP 不正确,也可能是由于保存的 ESP 的值被功能。在您的情况下,它似乎是后者。

我用这段代码编写了一个小型 C++ 项目,其行为与您的 C 程序类似:

#include <iostream>

extern "C" void __stdcall __MyMemcpy( void* __restrict, void* __restrict, int);

typedef struct {
    void(__stdcall* MemCpy)(void* __restrict, void* __restrict, int);
}MemFunc;

int initmemfunc(MemFunc* f) {
    f->MemCpy = __MyMemcpy;
    return 0;
}

char buf1[] = "Testing";
char buf2[200];

int main()
{
    MemFunc mf = { 0 };
    initmemfunc(&mf);
    mf.MemCpy(buf2, buf1, strlen(buf1));
    std::cout << "Hello World!\n" << buf2;
}

当我使用像您这样无法正确保存 ESIEDI 的代码时,我在 Visual Studio C/C++ 调试器中显示的生成的汇编代码中发现了这一点:

我已经注释了重要的部分。编译器已生成 C 运行时检查(这些可以禁用,但它们只会隐藏问题而不修复它),包括跨 STDCALL 函数调用的 ESP 检查。不幸的是,它依赖于将 ESP 的原始值(在推送参数之前)保存到寄存器 ESI 中。因此,在调用 __MyMemcpy 后会进行运行时检查,以查看 ESPESI 是否仍然是相同的值。如果不是,您会收到关于 ESP 未正确保存的警告。

由于您的代码错误地破坏了ESI(和EDI),因此检查失败。我已经对调试输出进行了注释,希望能提供更好的解释。


您可以避免使用LODSB/STOSB 循环来复制数据。有一条指令就是这个操作 (REP MOVSB) 复制 ESI 指向的 ECX 字节并将它们复制到 EDI。您的代码的一个版本可以写成:

__MyMemcpy PROC _dest : DWORD, _source : DWORD, _size : DWORD
    PUSH EDI                         ; Save registers first
    PUSH ESI
    MOV EDI, DWORD PTR [ESP + 0CH]   ; Arguments are offset by an additional 8 bytes
    MOV ESI, DWORD PTR [ESP + 10H]
    MOV ECX, DWORD PTR [ESP + 14H]
    REP MOVSB
    POP ESI                          ; Restore the caller (non-volatile) registers
    POP EDI
    RETN 0CH
__MyMemcpy ENDP

如果您要使用PROC 的强大功能来保存寄存器ESIEDI,您可以使用USES 指令列出它们。您还可以按名称引用堆栈上的参数位置。您还可以通过简单地使用ret 让 MASM 为调用约定生成正确的 EPILOGUE 序列。这将适当地清理堆栈,并且在 STDCALL 返回的情况下,通过从堆栈中删除指定数量的字节(即ret 0ch)在这种情况下,因为有 3 个 4 字节参数。

缺点是您必须生成 PROLOGUE 和 EPILOGUE 代码,这会使事情变得更加低效:

.MODEL FLAT, STDCALL
.CODE

__MyMemcpy  PROC USES ESI EDI dest : DWORD, source : DWORD, size : DWORD
    MOV EDI, dest
    MOV ESI, source
    MOV ECX, size
    REP MOVSB                        ; Use instead of LODSB/STOSB+Loop  
    RET
__MyMemcpy  ENDP

END

汇编器会为你生成这个代码:

PUBLIC __MyMemcpy@12
__MyMemcpy@12:
    push        ebp  
    mov         ebp,esp            ; Function prologue generate by PROC
    push        esi                ; USES caused assembler to push EDI/ESI on stack
    push        edi  
    mov         edi,dword ptr [ebp+8]  
    mov         esi,dword ptr [ebp+0Ch]  
    mov         ecx,dword ptr [ebp+10h]  
    rep movs    byte ptr es:[edi],byte ptr [esi]
    ; MASM generated this from the simple RET instruction to restore registers,
    ; clean up stack and return back to caller per the STDCALL calling convention
    pop         edi                ; Assembler
    pop         esi  
    leave  
    ret         0Ch  

有些人可能正确地争辩说,让汇编器掩盖所有这些工作会使代码可能更难理解,因为这些人没有意识到 MASM 可以使用 PROC 声明的函数进行特殊处理。这可能会导致将来更难为不熟悉 MASM 细微差别的其他人维护代码。如果您不了解 MASM 可能会生成什么,那么坚持自己编写函数的主体可能是一个更安全的选择。正如您所发现的,这还涉及关闭 PROLOGUE 和 EPILOGUE 代码生成。

【讨论】:

  • "请注意,如果您的源缓冲区和目标缓冲区重叠,这可能会导致问题。"这是真实的。但是,rep movsb 的可观察行为与原始的lodsb \stosb \loop 序列几乎完全相同。特别是,重叠缓冲区以相同的方式成功或失败。
  • @ecm 是的。我实际上应该将该评论移出该部分。我本来打算为他的所有代码实际注意这个问题,而不仅仅是我使用 rep movsb 的版本。
  • 感谢您的评论,您已经解决了我的问题!你必须保存所有这些寄存器是相当烦人的,因为它实际上会不必要地减慢你的代码,即使只是半纳秒。
  • @CarolVictor 您破坏的非易失性寄存器的保存和恢复开销是调用约定的必要弊端。与实际内存副本的开销相比,该开销可能相形见绌。
【解决方案2】:

堆栈损坏的原因是 MASM “秘密地”将序言代码插入到您的函数中。当我添加禁用它的选项时,该功能现在对我有用。

当您在 C 代码中切换到汇编模式然后单步执行您的函数时,您可以看到这一点。 VS 在汇编源中似乎没有切换到汇编模式。

.586
.MODEL FLAT,STDCALL
OPTION PROLOGUE:NONE 

.CODE

mymemcpy PROC dest:DWORD, src:DWORD, sz:DWORD
    MOV EDI, [ESP+04H]
    MOV ESI, [ESP+08H]
    MOV ECX, [ESP+0CH]
AGAIN_:
    LODSB
    STOSB
    LOOP AGAIN_
    RETN 0CH
mymemcpy ENDP

END

【讨论】:

  • 我试图在开始时推动它们并在最后弹出它们但没有运气
  • 我试过你的功能,现在可以了。看我的更新。我仍然认为ESI/EDI必须保存。
  • 抱歉,我看到您添加了无序幕选项。我应该指出,无论如何您都应该使用参数的名称,并允许汇编程序为您生成它们。如果您使用ret 而不是ret 0ch 并更改调用约定,则正确的序言代码将自动为调用约定生成适当的ret 指令。使用 STDCALl,MASM 将自动将ret 更改为ret 0ch。在使用 PROC 时,我的意见是,您最好使用它提供的功能,或者根本不理会它。
  • @MichaelPetch:如果您想了解事情是如何真正在幕后工作的,那么所有这些 MASM 魔法似乎都是您最不想要的。一旦你了解了 MASM 为你做了什么(例如通过检查反汇编),那么当调用约定不再是有趣的部分时,一定要使用它,但是知道如何关闭所有这些废话对于初学者来说似乎是个好主意真正理解 asm 调用约定。 (或者根本不使用 PROC,就像你建议的那样。)
  • 您必须意识到,PROC 指令诞生于一个分段成为问题的时代。 PROC 指令变得非常方便,可以为不同的代码模型生成适当的代码,而无需重写相同的函数。这在今天的平面模型中不太有用,但是 PROC 指令确实将调用约定名称修改作为函数的奖励,这就是我认为它经常被使用的原因。正如我在上面评论的那样——如果你不使用 PROC 的强大功能,可能根本不理会它。
猜你喜欢
  • 1970-01-01
  • 2012-04-13
  • 2013-03-03
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-05-17
  • 2014-04-06
  • 1970-01-01
相关资源
最近更新 更多