经常在调试分析dmup时,会看到很多线程栈在函数的后面会带上FPO,如下所示:
00 00eff818 777beb0d ffffffff 00000000 0107a2ec ntdll!NtTerminateProcess+0xc (FPO: [2,0,0])
01 00eff8f0 762c4f32 00000000 77e8f3b0 ffffffff ntdll!RtlExitUserProcess+0xbd (FPO: [Non-Fpo])
02 00eff904 71e74ebc 00000000 00eff958 71e7518e KERNEL32!ExitProcessImplementation+0x12 (FPO: [1,0,0])
03 00eff910 71e7518d 00000000 dd53d81d 00000000 MSVCR120!__crtExitProcess+0x15 (FPO: [Non-Fpo]) (CONV: cdecl) [f:ddvctoolscrtcrtw32startupcrt0dat.c @ 774]
04 00eff958 71e751b2 00000000 00000000 00000000 MSVCR120!doexit+0x115 (FPO: [Non-Fpo]) (CONV: cdecl) [f:ddvctoolscrtcrtw32startupcrt0dat.c @ 678]
*** WARNING: Unable to verify checksum for ConsoleApplication3.exe
05 00eff96c 01391a02 00000000 a2730342 01391a53 MSVCR120!exit+0xf (FPO: [Non-Fpo]) (CONV: cdecl) [f:ddvctoolscrtcrtw32startupcrt0dat.c @ 417]
06 00eff9a4 762c0419 00caa000 762c0400 00effa10 ConsoleApplication3!__tmainCRTStartup+0x114 (FPO: [Non-Fpo]) (CONV: cdecl) [f:ddvctoolscrtcrtw32dllstuffcrtexe.c @ 649]
07 00eff9b4 777c66dd 00caa000 65564319 00000000 KERNEL32!BaseThreadInitThunk+0x19 (FPO: [Non-Fpo])
08 00effa10 777c66ad ffffffff 777e53d5 00000000 ntdll!__RtlUserThreadStart+0x2f (FPO: [SEH])
09 00effa20 00000000 01391a53 00caa000 00000000 ntdll!_RtlUserThreadStart+0x1b (FPO: [Non-Fpo])
由于不影响具体问题的分析,一直没关注,今天又看到,且有点闲,就查了相关资料,补齐这块知识。
什么是FPO
要知道答案,你必须回到史前时代。英特尔的8088处理器有一组非常有限的寄存器(我忽略了段寄存器),它们是:
AX | BX | CX | DX | IP |
SI | DI | BP | SP | FLAGS |
有了这样一组有限的寄存器,这些寄存器都被指定了特定的用途。AX、BX、CX和DX是“通用”寄存器,SI和DI是“索引”寄存器,SP是“堆栈指针”,BP是“帧指针”,IP是“指令指针”,and FLAGS是一个只读寄存器,它包含若干位,这些位表示处理器当前状态的信息(例如,前一个算术或逻辑指令的结果是0)。
BX、SI、DI和BP寄存器是特殊的,因为它们可以用作“索引”寄存器。索引寄存器对编译器至关重要,因为它们用于通过指针访问内存。换句话说,如果您的结构位于内存中的偏移量0x1234处,则可以将索引寄存器设置为值0x1234并访问相对于该位置的值。例如:
MOV BX, [Structure]
MOV AX, [BX]+4
将BX寄存器设置为[Structure]指向的内存值,并将AX的值设置为相对于该结构开头的第4个字节处的字。
需要注意的一点是,SP寄存器不是索引寄存器。这意味着要访问堆栈上的变量,需要使用不同的寄存器,这就是BP寄存器的来源-BP寄存器专门用于访问堆栈上的值。
当386出现时,他们将各种寄存器扩展到32位,并且他们修正了只有BX、SI、DI和BP可以用作索引寄存器的限制。
EAX | EBX | ECX | EDX | EIP |
ESI | EDI | EBP | ESP | FLAGS |
这是件好事,突然之间,编译器可以使用其中的6个,而不是被限制在3个索引寄存器中。
因为索引寄存器是用来访问结构的,所以对于编译器来说,它们就像黄金一样——更多的索引寄存器是一件好事,而获得更多索引寄存器几乎是值得的。
一些非常聪明的人意识到,由于ESP现在是一个索引寄存器,EBP寄存器不再需要专门用于访问堆栈上的变量。下面是使用EBP:
MyFunction:
PUSH EBP
MOV EBP, ESP
SUB ESP, <LocalVariableStorage>
MOV EAX, [EBP+8]
:
:
MOV ESP, EBP
POP EBP
RETD
要访问堆栈上的第一个参数(EBP+0是EBP的旧值,EBP+4是返回地址),可以执行以下操作:
MyFunction:
SUB SP, <LocalVariableStorage>
MOV EAX, [ESP+4+<LocalVariableStorage>]
:
:
ADD SP, <LocalVariableStorage>
RETD
突然之间,EBP可以重新利用,并用作另一个通用寄存器!编译人员称这种优化为“帧指针省略(Frame Pointer Omission)”,它的首字母缩写是FPO。FPO是一种优化,它压缩或者省略了在栈上为该函数创建框架指针的过程。这个选项加速了函数调用,因为不需要建立和移除框架指针(ESP,EBP)了。同时,它还解放出了一个寄存器,用来存储使用频率较高的变量。只在IntelCPU的架构上才有这种优化。目前已经讨论过的任何一种调用约定都保存了前一函数中栈的信息(压栈ebp,然后让ebp = esp,再移动esp来保存局部变量)。一个FPO的函数可能会保存前一函数的栈指针(ESP,EBP),但是并不为当前的函数调用设立EBP。相反,他使用EBP来存储一些其他的变量。debugger 会计算栈指针,但是debugger必须得到一个使用FPO的提醒,该提醒是基于FPO类型的信息的来完成的。这项特性可以在MS Visual C++专业版和企业版中开启。使用的是编译器的/Oy选项。
FPO的数据结构可以在Microsoft的SDK中的winnt.h中找到,其中包含了描述栈框架内容的信息。这些信息被使用在debugger上,或者其他的需要在栈中寻找FPO函数的程序中。KV命令可以显示出包括FPO信息在内的额外的运行时信息。
但FPO有一个小问题。如果您查看MyFunction的pre-FPO示例,您会注意到例程中的第一条指令是PUSH EBP,然后是MOV EBP,特别是它有一个有趣且非常有用的副作用。它实际上创建了一个单独的链接列表,将每个调用者的帧指针链接到一个函数。从一个例程的EBP中,可以恢复一个函数的整个调用堆栈。这对于调试器来说是难以置信的有用——这意味着调用栈是相当可靠的,即使没有被调试器的所有模块的符号。不幸的是,当FPO被启用时,堆栈帧的列表丢失了——信息根本没有被跟踪。
为了解决这个问题,编译器人员将启用FPO时丢失的信息放入二进制文件的PDB文件中。因此,当有模块的符号时,可以恢复所有堆栈信息。
在NT 3.51中,所有的Windows二进制文件都启用了FPO,但是在Vista中的Windows二进制文件却被关闭了,因为它不再是必需的—机器从1995年开始变得足够快,以至于FPO所实现的性能改进不足以抵消FPO在调试和分析中所带来的痛苦。
FPO: [2,0,0]代表什么?
我们已经知道FPO是什么了,可是[2,0,0]是什么呢,看如下的表
FPO数据表示形式 | (FPO: [ebp addr][x,y,z]) |
x代表 | 作为参数压栈了的DWORDS个数 |
y代表 | 作为局部变量压栈了的DWORDS个数 |
z代表 | 在开场代码中(prologue)压栈了的寄存器个数 |
ebp addr代表 | 仅在EBP在开场代码中保存了的时候显示 |
本文最开始的例子中,由于调试器有正确的symbol,所以调试器会计算出栈底(Frame Pointer)的位置,而不是在EBP之中保存它。比如说,第一个参数的位置是栈底+0x8,返回值的位置是栈底+0x4. 开启了FPO之后,这些值就不能通过ebp + 0x8这样拿到了,跟ebp等值的栈底(Frame Pointer)需要计算才能拿到。