再谈FPO

在调试程序的过程中,可能遇到过一两次“FPO”这个词。FPO是指在x86上处理编译器如何访问本地变量和基于堆栈的参数的编译器优化的一个特定类型。对于使用局部变量(和/或基于堆栈的参数)的函数,编译器需要一种机制来引用堆栈上的这些值。通常,这是通过以下两种方式之一完成的:

  • 直接从堆栈指针(esp)访问局部变量。这是启用FPO优化时的行为。虽然这不需要单独的寄存器来跟踪局部变量和参数的位置,但如果禁用了FPO优化,这会使生成的代码稍微复杂一些。特别是,由于函数调用或修改堆栈的其他指令等原因,esp中局部变量和参数的位移实际上会随着函数的执行而改变。因此,编译器必须在引用基于堆栈的值的函数中的每个位置跟踪当前esp值的实际位移。对于编译器来说,这通常不是什么大问题,但是在手工编写的汇编程序中,这可能会变得有点棘手。
  • 指定一个寄存器指向堆栈上相对于局部变量和基于堆栈的参数的固定位置,并使用此寄存器访问局部变量和参数。这是禁用FPO优化时的行为。约定是使用ebp寄存器访问局部变量和堆栈参数。Ebp通常设置为第一个堆栈参数可以在[Ebp+08]中找到,而局部变量通常位于Ebp的负位移处。

禁用FPO优化的函数的典型情况可能如下所示:

push   ebp               ; save away old ebp (nonvolatile)
mov    ebp, esp          ; load ebp with the stack pointer
sub    esp, sizeoflocals ; reserve space for locals
...                      ; rest of function

主要的概念是禁用FPO优化,一个函数将立即保存ebp(作为第一个接触堆栈的操作),然后用当前堆栈指针加载ebp。这时的堆栈布局:

[ebp-01]   Last byte of the last local variable
[ebp+00]   Old ebp value
[ebp+04]   Return address
[ebp+08]   First argument...

此后,函数将始终使用ebp访问局部变量和基于堆栈的参数。(函数的汇编序列可能会有一些变化,特别是使用变化的函数设置初始SEH帧时,但相对于ebp,堆栈布局的最终结果始终相同。)
这确实(如前所述)使得ebp寄存器不可用于其他用途。但是,相对于打开FPO优化后编译的函数,此性能影响通常不足以成为一个大问题。此外,有许多情况下要求函数使用帧指针:

  • 任何使用SEH的函数都必须使用帧指针,因为当发生异常时,无法从异常分派时的esp值(堆栈指针)中知道局部变量的位移(异常可能发生在任何地方,而诸如进行函数调用或为函数调用设置堆栈参数之类的操作会修改esp的值。
  • 任何使用析构函数的C++对象都必须使用SEH来编译解压缩支持。这意味着大多数C++函数最终都被禁用了FPO优化。(可以改变编译器关于SEH异常和C++解卷的假设,但是默认的(和推荐的设置)是在出现SEH异常时取消对象。)
  •  任何使用alloca在堆栈上动态分配内存的函数都必须使用一个帧指针(因此禁用了FPO优化),因为esp对局部变量和参数的位移可以在运行时更改,编译器在生成代码时不知道。

由于这些限制,您可能正在编写的许多函数将已经禁用FPO优化,而没有显式地将其关闭。但是,仍有可能许多不符合上述条件的函数启用了FPO优化,因此不使用ebp引用局部变量和堆栈参数。

既然您已经大致了解了FPO优化的功能,那么我将在本系列的下半部分介绍为什么在调试某些类问题时全局关闭FPO优化对您有利。(事实上,大多数微软系统代码也会关闭FPO,因此您可以放心,已经在FPO和非FPO优化代码之间进行了真正的成本效益分析,在一般情况下禁用FPO优化总体上更好。)

考虑下面的示例程序,其中有几个不做任何事情的函数,这些函数将堆栈参数乱放并相互调用。(在本文中,我禁用了全局优化和函数内联。)

__declspec(noinline)
void
f3(
   int* c,
   char* b,
   int a
   )
{
   *c = a * 3 + (int)strlen(b);

   __debugbreak();
}

__declspec(noinline)
int
f2(
   char* b,
   int a
   )
{
   int c;

   f3(
      &c,
      b + 1,
      a - 3);

   return c;
}

__declspec(noinline)
int
f1(
   int a,
   char* b
   )
{
   int c;
   
   c = f2(
      b,
      a + 10);

   c ^= (int)rand();

   return c + 2 * a;
}

int
__cdecl
wmain(
   int ac,
   wchar_t** av
   )
{
   int c;

   c = f1(
      (int)rand(),
      "test");

   printf("%d\\n",
      c);

   return 0;
}

如果我们运行程序将在硬编码断点处中断,并加载符号,则一切都如预期的那样:

0:000> k
ChildEBP RetAddr  
0012ff3c 010015ef TestApp!f3+0x19
0012ff4c 010015fe TestApp!f2+0x15
0012ff54 0100161b TestApp!f1+0x9
0012ff5c 01001896 TestApp!wmain+0xe
0012ffa0 77573833 TestApp!__tmainCRTStartup+0x10f
0012ffac 7740a9bd kernel32!BaseThreadInitThunk+0xe
0012ffec 00000000 ntdll!_RtlUserThreadStart+0x23

不管FPO优化是打开还是关闭,由于我们加载了符号,无论哪种方式,我们都会得到一个合理的调用堆栈。不过,如果我们没有加载符号,情况就不同了。在同一个程序中,如果启用了FPO优化,并且没有加载符号,那么如果我们请求调用堆栈,就会有些混乱:

0:000> k
ChildEBP RetAddr  
WARNING: Stack unwind information not available.
Following frames may be wrong.
0012ff4c 010015fe TestApp+0x15d8
0012ffa0 77573833 TestApp+0x15fe
0012ffac 7740a9bd kernel32!BaseThreadInitThunk+0xe
0012ffec 00000000 ntdll!_RtlUserThreadStart+0x23

比较这两个调用堆栈,我们在输出中完全丢失了三个调用帧。我们得到稍微合理的结果的唯一原因是WinDbg的堆栈跟踪机制有一些智能的启发式方法来猜测调用帧在使用帧指针的堆栈中的位置。如果我们回顾一下如何使用帧指针设置调用堆栈,那么程序在x86上遍历堆栈而不使用符号的方式就是将堆栈视为一种链接的调用帧列表。回想一下,当使用帧指针时,我提到了堆栈的布局:

[ebp-01]   Last byte of the last local variable
[ebp+00]   Old ebp value
[ebp+04]   Return address
[ebp+08]   First argument...

这意味着,如果我们尝试执行没有符号的堆栈遍历,那么方法是假设ebp指向类似于以下内容的“结构”:

typedef struct _CALL_FRAME
{
   struct _CALL_FRAME* Next;
   void*               ReturnAddress;
} CALL_FRAME, * PCALL_FRAME;

注意这是如何对应于我上面描述的相对于ebp的堆栈布局的。一个非常简单的堆栈遍历函数,设计用于遍历使用帧指针编译的帧,可能看起来是这样的(使用_addressorreturnaddress内在函数查找“ebp”,假设旧ebp在返回地址的地址之前4个字节):

LONG
StackwalkExceptionHandler(
   PEXCEPTION_POINTERS ExceptionPointers
   )
{
   if (ExceptionPointers->ExceptionRecord->ExceptionCode
      == EXCEPTION_ACCESS_VIOLATION)
      return EXCEPTION_EXECUTE_HANDLER;

   return EXCEPTION_CONTINUE_SEARCH;
}

void
stackwalk(
   void* ebp
   )
{
   PCALL_FRAME frame = (PCALL_FRAME)ebp;

   printf("Trying ebp %p\\n",
      ebp);

   __try
   {
      for (unsigned i = 0;
          i < 100;
          i++)
      {
         if ((ULONG_PTR)frame & 0x3)
         {
            printf("Misaligned frame\\n");
            break;
         }

         printf("#%02lu %p  [@ %p]\\n",
            i,
            frame,
            frame->ReturnAddress);

         frame = frame->Next;
      }
   }
   __except(StackwalkExceptionHandler(
      GetExceptionInformation()))
   {
      printf("Caught exception\\n");
   }
}

#pragma optimize("y", off)
__declspec(noinline)
void printstack(
   )
{
   void* ebp = (ULONG*)_AddressOfReturnAddress()
     - 1;

   stackwalk(
      ebp);
}
#pragma optimize("", on)

如果我们重新编译程序,禁用FPO优化,并在f3函数中插入对printstack的调用,控制台输出如下:

Trying ebp 0012FEB0
#00 0012FEB0  [@ 0100185C]
#01 0012FED0  [@ 010018B4]
#02 0012FEF8  [@ 0100190B]
#03 0012FF2C  [@ 01001965]
#04 0012FF5C  [@ 01001E5D]
#05 0012FFA0  [@ 77573833]
#06 0012FFAC  [@ 7740A9BD]
#07 0012FFEC  [@ 00000000]
Caught exception

换句话说,在不使用任何符号的情况下,我们在x86上成功地执行了堆栈遍历。但是,当调用堆栈中的某个函数不使用帧指针(即在启用了FPO优化的情况下编译)时,这一切都会崩溃。在这种情况下,认为ebp总是指向一个CALL_FRAME结构的假设不再有效,并且调用堆栈要么被截短,要么完全错误(特别是当有问题的函数将ebp重新用作除帧指针之外的其他用途时)。尽管可以使用启发式方法来尝试猜测结构上真正的调用/返回地址记录,但这实际上不过是一个有根据的猜测,而且往往至少有一点错误(通常完全丢失一个或多个帧)。
现在,您可能想知道为什么您可能关心不带符号的堆栈遍历操作。毕竟,您的程序将要调用的Microsoft二进制文件的符号(如kernel32)可从Microsoft symbol服务器获得,并且您(可能)有与您自己的程序对应的私有符号,以便在调试问题时使用。
好吧,答案是,在正常的调试过程中,您将需要在没有符号的情况下记录堆栈跟踪,以解决各种各样的问题。原因是NTDLL(和NTOSKRNL)中提供了大量支持,以帮助调试一类特别隐蔽的问题:处理泄漏(以及在某些地方关闭错误的句柄值并需要找出原因的其他问题)、内存泄漏和堆损坏。
这些(非常有用!)调试功能提供了一些选项,允许您将系统配置为在每次堆分配、堆空闲或每次打开或关闭句柄时记录堆栈跟踪。现在,这些特性的工作方式是,当堆操作或句柄操作发生时,它们将实时捕获堆栈跟踪,而不是试图闯入调试器以显示此输出的结果(由于许多原因,这是不可取的),它们将当前堆栈跟踪的副本保存在内存中,然后继续正常执行。要显示这些保存的堆栈跟踪,请!哦,比赛!希普,还有!avrf命令具有在内存中定位这些保存的跟踪并将其打印到调试器供您检查的功能。
但是,NTDLL/NTOSKRNL首先需要一种方法来创建这些堆栈跟踪,以便它可以保存它们以供以后检查。这里有几个要求:

  • 捕获堆栈跟踪的功能不能依赖于NTDLL或NTOSKRNL之上的任何内容。这已经意味着,任何像通过DbgHelp下载和加载符号这样复杂的事情都会立即消失,因为这些函数的层次远远高于NTDLL/NTOSKRNL(事实上,它们必须调用将堆栈跟踪记录在案的同一个函数才能找到符号)。
  • 当调用堆栈上所有内容的符号都不可用于本地计算机时,该功能必须起作用。例如,这些功能必须部署在客户计算机上,而不以某种方式让该计算机访问您的私有符号。因此,即使有一个很好的方法来定位正在捕获堆栈跟踪的符号(实际上没有),如果愿意的话,您甚至找不到这些符号。
  • 该功能必须在内核模式下工作(用于保存句柄跟踪),因为句柄跟踪部分由内核本身管理,而不仅仅是NTDLL。
  • 该功能必须使用最少的内存来存储每个堆栈跟踪,因为在进程的整个生命周期中,堆分配、堆释放、句柄创建和句柄关闭等操作都是非常频繁的操作。因此,不能使用仅保存整个线程堆栈以供以后在符号可用时检查的选项,因为对于每个保存的堆栈跟踪来说,这将非常昂贵。

考虑到所有这些限制,负责保存堆栈跟踪的代码需要在没有符号的情况下运行,而且它还必须能够以非常简洁的方式保存堆栈跟踪(不需要为每个跟踪使用大量内存)。
因此,在x86上,NTDLL和NTOSKRNL中的堆栈跟踪保存代码假定调用帧中的所有函数都使用帧指针。这是在没有符号的x86上保存堆栈跟踪的唯一实际选项,因为没有足够的信息烘焙到每个单独编译的二进制文件中,无法可靠地执行堆栈跟踪,而不假设在每个调用站点使用帧指针。(Windows支持的64位平台通过使用大量的展开元数据解决了这个问题,正如我在过去的一些文章中所述。)
因此,pageheap的堆栈跟踪日志记录和句柄跟踪所公开的功能是,当您试图调试问题时,没有符号的堆栈跟踪最终会对您(开发人员)很重要,因为您的所有二进制文件都有符号。如果确保对所有代码禁用FPO优化,则可以使用pageheap的堆栈跟踪堆操作、UMDH(用户模式堆调试器)和handle跟踪等工具来跟踪堆相关问题和处理相关问题。这些功能中最棒的部分是,您甚至可以将它们部署到客户站点上,而无需安装完整的调试器(或在调试器下运行程序),只需稍后在实验室中对您的进程进行小型转储即可进行检查。不过,所有这些功能都依赖于禁用的FPO优化(至少在x86上是这样的),因此,请记住在您的发布版本上关闭FPO优化,以提高这些难以发现的现场问题的可调试性。

posted on 2020-06-10 08:38  活着的虫子  阅读(931)  评论(0编辑  收藏  举报

导航