基指针和栈指针到底是什么?它们指向什么?

283
使用来自维基百科的这个例子,其中DrawSquare()调用DrawLine()

diagram of stack with annotations

(请注意,此图中高地址在底部,低地址在顶部。)
有人能解释一下在这个上下文中ebpesp是什么吗?
从我所看到的,我会说栈指针总是指向栈的顶部,而基指针指向当前函数的开头,对吗?
编辑:我是指在Windows程序的上下文中。
编辑2:那么eip是如何工作的呢?
编辑3:我有来自MSVC++的以下代码:
var_C= dword ptr -0Ch
var_8= dword ptr -8
var_4= dword ptr -4
hInstance= dword ptr  8
hPrevInstance= dword ptr  0Ch
lpCmdLine= dword ptr  10h
nShowCmd= dword ptr  14h

所有这些似乎都是双字(dwords),因此每个占用4个字节。所以我可以看到从hInstancevar_4之间有4个字节的间隙。它们是什么?我猜它是返回地址,正如维基百科的图表中所示。
(编辑注:从迈克尔的回答中删除了一段长引文,该引文不属于问题,但编辑了一个后续问题):
这是因为函数调用的流程如下:
- 推送参数(hInstance等) - 调用函数,该函数推送返回地址 - 推送ebp - 为局部变量分配空间 我的问题(希望这是最后一个!)是,从我弹出要调用的函数的参数到过程前导结束时,到底发生了什么?我想知道ebp和esp在这段时间内是如何演变的(我已经理解了前导的工作,我只想知道在我将参数推入堆栈之后和前导之前发生了什么)。

29
值得注意的一点是,栈在内存中是向“下”生长的。这意味着要将栈指针向上移动,需要减少其值。 - B.S.
5
区分EBP/ESP和EIP的一个提示:EBP和ESP处理数据,而EIP处理代码。 - mmmmmmmm
你的意思是,如果我调用一个名为DrawPixel()的新函数,它会出现在当前堆栈的顶部,ESP会减少,对吗?然后在函数返回后,它会再次增加(所以图片看起来就像现在这样)? - devoured elysium
3
在你的图表中,ebp(通常)是“帧指针”,esp是“堆栈指针”。这样可以通过[ebp-x]访问局部变量和通过[ebp+x]访问堆栈参数,与堆栈指针无关(在函数内经常发生变化)。可以通过ESP进行地址寻址,释放EBP以进行其他操作,但这种方式调试程序无法确定调用堆栈或局部变量的值。 - peterchen
4
并不一定。一些编译器将堆栈帧放入堆中。堆栈向下增长的概念只是一个易于理解的概念。堆栈的实现可以是任何形式(使用随机堆块使得覆盖堆栈部分的黑客攻击更加困难,因为它们不够确定)。 - Martin York
2
两个词:栈指针允许 push/pop 操作正常工作(因此 push 和 pop 知道在哪里放置/获取数据)。基指针允许代码独立引用先前推送到堆栈上的数据。 - tigrou
6个回答

284

esp是你所说的堆栈顶部。

ebp通常在函数开始时设置为esp。通过从ebp中加减一个常量偏移量来访问函数参数和局部变量。所有x86调用约定都将ebp定义为跨函数调用保留的寄存器。实际上,ebp本身指向前一帧的基指针,这使得在调试器中进行堆栈遍历并查看其他帧的局部变量成为可能。

大多数函数的函数前导部分看起来像:

push ebp      ; Preserve current frame pointer
mov  ebp, esp ; Create new frame pointer pointing to current stack top
sub  esp, 20  ; allocate 20 bytes worth of locals on stack.

然后在函数中稍后可能会有像这样的代码(假设两个本地变量都是4字节)

mov  [ebp-4], eax   ; Store eax in first local
mov  ebx, [ebp - 8] ; Load ebx from second local

你可以启用FPO或帧指针省略优化,这将消除这个问题,并使用ebp作为另一个寄存器,并直接从esp访问本地变量,但这会使调试稍微困难一些,因为调试器不能再直接访问早期函数调用的堆栈帧。

编辑:

对于你更新的问题,堆栈中缺少的两个条目是:

nShowCmd          = dword ptr +14h
hlpCmdLine        = dword ptr +10h
PrevInstance      = dword ptr +0Ch
hInstance         = dword ptr +08h
return address    = dword ptr +04h     <==
savedFramePointer = dword ptr +00h     <==
var_4             = dword ptr -04h
var_8             = dword ptr -08h
var_C             = dword ptr -0Ch

这是因为函数调用的流程如下:

  • 推送参数 (hInstance, PrevInstance, hlpCmdLine, nShowCmd)
  • 调用函数,并将返回地址压入堆栈
  • 推送 ebp
  • 为局部变量分配空间

1
谢谢解释!但是我现在有点困惑。假设我调用一个函数,而且我还没有执行它的任何一行代码,我现在在它的 prolog 的第一行。此时,ebp 的值是多少?此时堆栈除了推送的参数之外还有什么吗?谢谢! - devoured elysium
4
EBP并不会神奇地改变,因此,在您为函数建立新的EBP之前,您仍将拥有调用者的值。除了参数之外,栈还将保存旧的EIP(返回地址)。 - MSalters
3
好的回答。但如果没有提及附录中的“leave”和“ret”指令,它就不完整了。 - Calmarius
2
我认为这张图片将有助于阐明一些流程问题。还要记住堆栈是向下增长的。http://ocw.cs.pub.ro/courses/_media/so/laboratoare/call_stack.png - Andrei-Niculae Petre
是我看错了,还是上面的代码片段中所有的减号都不见了? - BarbaraKwarc
我之后应该将esp加上20吗?(因为你先执行了sub esp,20) - user2622574

150

ESPStack Pointer)是当前栈指针,每当在堆栈上推送或弹出字或地址时它都会发生改变。相比直接使用ESPEBPBase Pointer)更方便编译器用于跟踪函数的参数和局部变量。

通常来说(具体实现可能因编译器而异),调用函数时,所有参数都由调用函数推送到堆栈中(通常是与函数原型声明顺序相反,但这也有例外)。然后调用函数,将返回地址EIPInstruction Pointer)推送到堆栈中。

进入函数时,旧的EBP值被推送到堆栈中,EBP设置为ESP的值。此后,ESP被递减(因为堆栈在内存中向下增长),以分配函数的局部变量和临时变量的空间。在函数执行期间,函数参数位于EBP的正偏移处(因为它们在函数调用前被推送),而局部变量位于EBP的负偏移处(因为它们在函数进入后在堆栈中分配)。这就是为什么称EBP帧指针,因为它指向函数调用框架的中心。

函数退出时,它所要做的就是将ESP设置为EBP的值(这样可以释放栈中的局部变量,并在栈的顶部暴露入口EBP),然后从栈中弹出旧的EBP值,最后函数返回(将返回地址弹出到EIP中)。

当返回到调用函数时,它可以通过增加ESP来删除刚在调用其他函数之前推送到堆栈上的函数参数。此时,堆栈回到了调用所需函数之前的状态。


4
到目前为止,我读过的关于ESP和EBP的最好解释。 - Chris Bao
3
对于汇编新手来说,可能会有一个误导性的观念,即在EBP中的BP代表Base Pointer(基指针),但实际上并非如此,因为 EBP 指向栈帧的中心位置,而不是底部。Frame Pointer(帧指针)似乎更适合作为这个寄存器的名称。 - Anis LOUNIS aka AnixPasBesoin
一些中央处理器实际上拥有一个称为“帧指针”的寄存器,例如数字 Alpha CPU。 - David R Tribble
我认为ESP不会改变,而是在程序的整个生命周期中保持不变,因为它只是表示堆栈(顶部)的起始点? - Epic Speedy
2
@EpicSpeedy 不是的,ESP 指向你最后推到堆栈上的项目(或其顶部的空闲空间,我不记得具体是哪个了)。无论如何,当你 push/pop 寄存器或调用函数/从函数返回时,它都会发生变化。 - puppydrum64
这样对吗:被调用者的 EBP = 调用者的 ESP - 参数总大小 - 返回地址大小? - Alex Luya

18

你说得没错。栈指针指向栈的顶部项,而基指针则指向函数调用之前的“先前”栈顶

当你调用一个函数时,任何局部变量都将存储在栈上,栈指针会增加。当你从函数返回时,栈上的所有局部变量都会超出作用域。你可以通过将栈指针设置回基指针(即函数调用之前的“先前”栈顶)来实现这一点。

用这种方式进行内存分配非常快速高效


16
当你说“之前”的栈顶指的是函数调用之前的栈顶时,你忽略了在调用函数之前被推入栈中的参数以及调用者EIP。这可能会让读者感到困惑。我们只需说,在标准的堆栈帧中,EBP指向与ESP相同的地方,即进入函数后的那个位置。 - wigy
当您将某些内容推入堆栈时,堆栈指针将被递减,因为堆栈底部具有最高地址。 - bwass31

8

编辑:更好的描述请参考关于x86汇编的WikiBook中的函数和栈帧。 我尝试添加一些您可能感兴趣的使用Visual Studio的信息。

将调用者EBP作为第一个局部变量存储被称为标准堆栈帧,这可以用于Windows上几乎所有的调用约定。 传递的参数由调用方或被调用方释放以及哪些参数在寄存器中传递存在差异,但这些与标准堆栈帧问题是正交的。

谈到Windows程序,您可能会使用Visual Studio来编译C ++代码。 请注意,Microsoft使用一种称为Frame Pointer Omission的优化,这使得在不使用dbghlp库和可执行文件的PDB文件的情况下几乎不可能遍历堆栈。

此Frame Pointer Omission意味着编译器不会将旧的EBP存储在标准位置并使用EBP寄存器处理其他事项,因此如果不知道本地变量需要多少空间,则很难找到调用者EIP。 当然,Microsoft提供了一个API,即使在这种情况下也允许您进行堆栈遍历,但在某些用例中查找PDB文件中的符号表数据库需要太长时间。

要避免在编译单元中使用FPO,您需要避免使用/O2或需要在项目中显式添加/Oy-到C ++编译标志。 您可能链接到C或C ++运行时,在发布配置中使用FPO,因此如果没有dbghlp.dll,则很难进行堆栈遍历。


我不明白EIP如何存储在堆栈中。它不应该是一个寄存器吗?一个寄存器怎么可能在堆栈上呢?谢谢! - devoured elysium
调用者EIP由CALL指令本身推入堆栈。RET指令只是获取堆栈顶部并将其放入EIP中。如果存在缓冲区溢出,则可能利用此事实从特权线程跳转到用户代码。 - wigy
@devouredelysium EIP寄存器的内容(或值)被放置在堆栈上,而不是寄存器本身。 - BarbaraKwarc
@BarbaraKwarc 感谢您宝贵的意见。我没有看出 OP 在我的答案中缺少什么。实际上,寄存器保持不变,只有它们的值从 CPU 发送到 RAM。在 amd64 模式下,这变得更加复杂,但留待另一个问题讨论。 - wigy
那么关于那个amd64呢?我很好奇。 - BarbaraKwarc
那么,栈的某些部分就在 CPU 内部,对于栈的深层部分,RAM 和 CPU 之间会发生一些神奇的同步。 - wigy

6

首先,由于x86堆栈是从高地址值到低地址值构建的,因此堆栈指针指向堆栈底部。堆栈指针是下一个push(或call)调用将放置下一个值的位置。其操作相当于C / C ++语句:

 // push eax
 --*esp = eax
 // pop eax
 eax = *esp++;

 // a function call, in this case, the caller must clean up the function parameters
 move eax,some value
 push eax
 call some address  // this pushes the next value of the instruction pointer onto the
                    // stack and changes the instruction pointer to "some address"
 add esp,4 // remove eax from the stack

 // a function
 push ebp // save the old stack frame
 move ebp, esp
 ... // do stuff
 pop ebp  // restore the old stack frame
 ret

基指针是当前帧的顶部。ebp通常指向您的返回地址。 ebp + 4指向函数的第一个参数(或类方法的this值)。 ebp-4指向函数的第一个局部变量,通常是ebp的旧值,因此您可以恢复先前的帧指针。


4
不,ESP不指向栈的底部。内存寻址方案与此无关。无论堆栈增长到较低或较高的地址,都不重要。堆栈的“顶部”始终是下一个值将被推送的位置(放在堆栈的顶部),或者在其他体系结构上,最后一个推送的值已经被放置并且当前所在的位置。因此,ESP始终指向堆栈的顶部。 - BarbaraKwarc
2
栈的底部或基础,是指放置第一个(或最旧)值的地方,然后被更近期的值覆盖。这就是 EBP 的“基指针”名称的由来:它应该指向子程序当前本地堆栈的基础(或底部)。 - BarbaraKwarc
芭芭拉,在Intel x86中,堆栈是倒置的。堆栈的顶部包含第一个推入堆栈的项目,每个后续项目都被推入到顶部项目的下方。堆栈的底部是新项目放置的地方。程序从1k开始放置在内存中,并向无限增长。堆栈从无限开始,实际上是最大内存减去ROM,然后向0增长。ESP指向一个地址,其值小于第一个推入的地址。 - jmucchiello

1

我已经很久没有做汇编语言编程了,但是这个链接可能会有用...

处理器有一组寄存器,用于存储数据。其中一些是直接值,而其他一些则指向RAM中的某个区域。寄存器确实倾向于用于某些特定的操作,汇编中的每个操作数都需要特定寄存器中的一定量数据。

堆栈指针在调用其他过程时大多数情况下使用。使用现代编译器,一堆数据将首先被转储到堆栈上,然后是返回地址,以便系统知道在告知其返回后返回到哪里。堆栈指针将指向可以将新数据推送到堆栈的下一个位置,在那里它将保留,直到再次弹出。

基址寄存器或段寄存器只是指向大量数据的地址空间。与第二个寄存器结合使用,基址指针将内存分成巨大的块,而第二个寄存器将指向该块中的一个项目。因此,基址指针指向数据块的基址。

请记住,汇编非常CPU特定。我提供的页面提供了有关不同类型CPU的信息。


在x86上,段寄存器是分开的 - 它们是gs、cs、ss,除非你正在编写内存管理软件,否则你永远不会触及它们。 - Michael
ds也是一个段寄存器,在MS-DOS和16位代码时代,你肯定需要偶尔更改这些段寄存器,因为它们最多只能指向64 KB的RAM。然而,DOS可以访问高达1 MB的内存,因为它使用20位地址指针。后来我们得到了32位系统,一些带有36位地址寄存器,现在是64位寄存器。所以现在你不需要再改变这些段寄存器了。 - Wim ten Brink
@Paul:错误!错误!错误!16位段被32位段所取代。在保护模式下,这允许内存虚拟化,基本上允许处理器将物理地址映射到逻辑地址。然而,在您的应用程序中,事情似乎仍然是平面的,因为操作系统已经为您虚拟化了内存。内核在保护模式下运行,允许应用程序在平面内存模型中运行。另请参见http://en.wikipedia.org/wiki/Protected_mode。 - Wim ten Brink
@MSalters,那不完全正确。他们为自己执行的进程执行此操作,为这些进程提供虚拟内存,因此这些段不需要。操作系统只是隐藏了内存分段,但它仍然在内部使用段。32位系统的Watcom C/C++实际上支持在进行远程调用时使用段!更多信息请访问http://www.users.pjwstk.edu.pl/~jms/qnx/help/watcom/compiler-tools/wmodl386.html Watcom C/C++现在是OpenWatcom:http://www.openwatcom.org/ - Wim ten Brink
如果我没记错的话,FS指向当前进程的命令行和环境表。在一个正常的设置中,DS=ES=SS。FS被映射,以便FS:00000000指向程序地址空间,在程序启动期间复制命令行和环境。 - jmucchiello
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接