x64 平台上的简单 C++ 分析器






4.98/5 (27投票s)
x64 平台 C++ 应用程序的简单分析器
- 下载 MFCAppProfile_Exe.zip - 127 KB
- 下载 MultiThreaded.zip - 16.1 KB
- 下载 MFCAppProfiler.zip - 157.7 KB
- 下载 ProfilerX64.zip - 77.7 KB
引言
市面上有许多性能分析器,有些我用不了,因为我的平台/IDE不支持,或者它是付费的。而且商业产品价格昂贵,也不能按照我们想要的方式进行定制。例如,如何从性能分析中排除一些函数?比如说,如果我有一个MFC应用程序,会收到大量的绘制消息,而我不想分析它们。如何做到呢?所以我开始编写自己的性能分析器来分析C++程序。请注意,这不是分析其他应用程序的性能分析器,而是我们修改目标进程使其能够自我分析。是的,没错,我能理解你的意思。有很多开源的性能分析器代码,为什么我们不使用呢?我在网上花了很长时间寻找一个可用于x64平台的性能分析器代码。大多数性能分析器都支持x86而不是x64。我甚至联系了一些在x86上实现性能分析器的作者,询问如何在x64上实现。他们要么忙于日常工作,要么对x64不太确定。
所以我的计划是,与其花时间寻找x64的性能分析器,不如自己开发?我的要求很简单:我想测量应用程序生命周期中所有已执行函数的近似耗时。显然,我也想过滤掉一些由我们的函数间接调用的函数,比如std::xxxx函数、C++库函数调用等。
局限性
这项工作有一些局限性,我将在最开始列出。这样听众就可以决定是否继续深入研究。
- 此性能分析器不能分析其他应用程序。相反,本文重点介绍如何修改应用程序使其能够进行自我性能分析。这可能涉及更改项目设置、向现有项目添加文件以及将DLL添加到您的应用程序中以完成性能分析工作。
- 这可能不适用于发布模式,因为我们严重依赖调试信息来查找函数名等。
背景
当我开始搜索性能分析器时,我(幸运地)首先想到的是Microsoft C/C++编译器支持的编译器标志,如/GH和/Gh。这些选项允许用户注入一个函数,该函数可以在任何函数执行之前和函数退出时被调用。
当我浏览x86上的一些性能分析器时,它们都以此为基础进行构建。所以我找到了一个前进的基础。好的,开始吧!
我的想法很简单
- 由于_penter会在每次函数调用时被调用,让我们从_penter被调用时开始计时。
- 在_pexit处停止计数。
非常简单,听起来不错。但当我开始动手实践时,我发现了一些严重的问题。
问题1:我只是添加了_penter和_pexit并编译了我的应用程序。哦不,我没有成功。因为这些函数应该遵循naked调用约定。x64不支持这种调用约定。
问题2:假设我设法添加了它们,_penter和_pexit本身什么也不做。它们只是骨架。用户需要提供实现。文档说明,这些函数应该负责管理过程序言和结尾,通过压入所需的寄存器并将它们弹出。这会引发多个问题。
- 应该压入哪些寄存器?
- 如何压入寄存器?x86支持使用pushad和popad压入和弹出寄存器。x64没有这些函数。
- 等等!只能在内联汇编代码中访问寄存器,而内联汇编代码通常以_asm关键字开头。哦亲爱的,x64不支持内联汇编代码。请参考此链接以获取更多信息。那么如何在x64中使用汇编代码呢?
我们一个一个来,解开这个结。
步骤1:添加_penter和_pexit
由于x64不支持_penter和_pexit,我决定将它们移到汇编代码中。但我已经提到,VS在x64上不支持内联汇编。是的,不支持内联汇编,但我们可以将它们放在单独的汇编文件中 [参考]。让我们创建一个汇编文件(.asm扩展名),并为_penter和_pexit添加骨架。
- 要将.asm文件添加到现有项目中 -> 右键单击项目 -> 添加 -> 新项 -> 将过滤器设为C++文件(.cpp)-> 输入文件名xxx.asm。
完成。但是如何编译.asm文件呢?
步骤2:编译.asm文件
由于Visual Studio本身没有内置的编译.asm文件的功能,我们需要借用外部工具来完成这项工作。这可以使用MASM汇编器来完成。我将告诉你简单的步骤。添加.asm文件后,请指定如何编译这些文件。
右键单击项目 -> 生成自定义 -> 选择masm。
步骤3:填充_penter
堆栈操作
我希望你们都了解函数调用过程中堆栈是如何被操作和管理的。如果不了解,请参考此处。即使它提供了x86的详细信息,您也应该了解堆栈管理。
让我们来看一个没有/GH和/Gh标志的简单程序的反汇编代码。
#include <iostream>
using namespace std;
int Foo(int a, int b )
{
return ( a+b);
}
void main( )
{
cout<<Foo(5,6);
}
对于上面的代码,在进入main时,堆栈指针[RSP]指向00000000002FF9E8。让我们看看该内存位置的内容。它是6d 40 03 3f 01 00 00 00,这不过是000000013f03406d的低位优先表示。000000013f03406d是main的返回地址。
我想展示的是,在函数开始时RSP指向返回指令的地址。另外,在x86中ebp指向帧指针,而在x64中rdi指向帧指针。
上面的跟踪是针对未用/Gh和/GH开关编译的程序。但我们将使用/GH和/Gh标志编译我们的项目。我将展示另一个带有这些开关的示例程序。
extern "C" void _penter;
extern "C" void _pexit;
void Subtract( int a, int b )
{
int c = a - b;
cout<<" c = "<<c<<endl;
}
void main()
{
Subtract(5,3);
}
我们可以看到main函数的_penter和_pexit的条目。同样,如果查看Subtract函数的反汇编,您会发现_penter和_pexit的条目。
在_penter和_pexit中应该填充什么?
由于用户有责任提供_penter和_pexit的定义,我们应该知道要填充什么。文档还说明,这些函数应该负责在进入时压入寄存器内容,并在退出时弹出。
如前所述,对于x86,pushad和popad是现成的。x64呢?应该考虑哪些x64寄存器?[参考x86寄存器详情]。
我们应该关注易失性寄存器 [参考此处]。由于我们不操作浮点寄存器,我们的目标是RAX、RCX、RDX、R8、R9、R10和R11。由于没有可以完成此工作的调用,我们必须单独显式地压入和弹出这些寄存器。
现在我们知道在_penter和_pexit中应该存储和恢复哪些寄存器。
_penter proc
push r11
push r10
push r9
push r8
push rax
push rdx
push rcx
堆栈对齐:x64要求堆栈指针是16字节对齐的。
此时,由于我们压入了7个寄存器(每个8字节),堆栈应该会失对齐(7 * 8 = 56字节,不是16的倍数)。但是在_penter进入时,返回地址会被压入堆栈。这是_penter完成执行后控制应转移到的指令的地址。因为这个原因,堆栈现在完美对齐了(8 [返回地址] + 56 = 64字节)。您可以注意到,在_penter开始时,堆栈指针也指向该地址。
x64调用约定
此时您应该了解x64调用约定 [参考此处]。它类似于__fastcall调用约定,其中函数的第一个4个整数参数通过寄存器 [RCX, RDX, R8, 和 R9] 传递。之后的所有参数都压入堆栈。如果参数是浮点数,则前4个参数通过XMM0到XMM3传递,其余的压入堆栈。
在_penter中,即使第一个4个寄存器是通过RCX、RDX、R8和R9传递的,我们也应该为它们预留空间。这可能是因为,后来在函数内部如果引用了参数的地址,那么堆栈上的这个空间将被使用。我对此不太确定。
_penter proc
push r11
push r10
push r9
push r8
push rax
push rdx
push rcx
; reserve space for 4 registers [ RCX,RDX,R8 and R9 ]
sub rsp,20h
图3。
计算函数地址
现在要获取函数的返回地址,我们需要向上移动88字节(因为在返回地址之后,压入了7个寄存器[7 * 8 = 56字节]并预留了32字节。所以堆栈总共增长了56 + 32 = 88 [58h]字节)。这正是_penter的返回地址。
一旦知道返回地址,我们就可以通过从返回地址减去5个字节来找到调用_penter的指令的地址。因为x86中的call指令是5个字节。指令[CALL]是1字节,操作数[函数地址]是4字节。
这对于x86来说还可以。但对于x64不是9个字节吗?[1字节指令 + 8字节操作数]。不。仍然是5个字节。怎么会这样?在x86中,操作数是4字节绝对值。
CALL DWORD PTR[xxxxxxxx] -> xxxxxxxx是函数地址。
对于x64,操作数是相对于其被调用指令地址的偏移量。
00300200 : CALL DWORD PTR [xxxxxxxx] -> 所以函数实际位于地址00300200 + xxxxxxxx,其中xxxxxxxx是从00300200开始的4字节偏移量。由于操作数是4字节偏移量,所以仍然是5个字节。
您可以在图2中看到这一点。
返回地址 (000000013F4C14BA) - 5 = 000000013F4C14B5。
这个地址(000000013F4C14B5)需要传递给DLL中的导出函数(FindSymbol),该函数用于查找函数的名称。为了将此地址传递给函数,我们只需将其值移动到RCX寄存器。因为参数是通过寄存器传递的。参考。
_penter proc
push r11
push r10
push r9
push r8
push rax
push rdx
push rcx
; reserve space for 4 registers [ RCX,RDX,R8 and R9 ]
sub rsp,20h
; Get the return address of the function
mov rcx,rsp
mov rcx,qword ptr[rcx+58h]
sub rcx,5
; Call the exported function in dll which finds the name of the function
FindSymbol
步骤4:获取函数名和开始时间
一旦获得函数地址,我们就可以使用Debug帮助函数获取函数名。这在一个单独的DLL中实现。请注意,该DLL是在不使用/GH和/Gh标志的情况下编译的。该DLL有一个导出函数FindSymbol,它接收调用函数的地址并使用调试帮助函数查找其名称。这就是_penter中调用的函数。好的,我们知道了函数地址,如何获取它的名称?
InitSymbols
让我们进行逆向工程来找出给定符号的名称。
- 我们应该加载模块的所有符号。
- 我们如何知道要加载符号的模块名称?我们可以使用GetModuleFileName函数,通过函数的基地址来获取它。
- 如何获取函数基地址?调用VirtualQuery方法检索给定地址的信息。
- 这就是InitSymbols函数的作用。
DLL有入口点函数Dllmain。当此函数以DLL_PROCESS_ATTACH的原因被调用时(通常在DLL在进程启动时通过隐式链接加载时发生),获取DLL加载的基地址。我使用此地址初始化当前进程的符号处理程序。这在InitSymbols函数中完成。
当此函数从_penter调用时,一个地址作为参数传递给此函数。此函数调用一个辅助函数FindFunction。此函数为PSYMBOL_INFO结构分配内存,并调用SymFromAddr函数来获取函数名称。
收到的名称(可在SYMBOL_INFO::Name中找到)可能由于C++名称修饰方案而被修饰。要获取未修饰的名称,请调用UnDecorateSymbolName函数。
获取函数名称后,会创建一个ProfileInfo结构的实例。此结构存储函数名称、线程ID、函数开始时间、函数结束时间等。然后使用QueryPerformanceCounter记录时间。这是函数的近似开始时间。然后将此实例添加到映射g_mapProfileInfo中。
此映射的键是线程ID,值是ProfileInfo实例的向量。向量的每个元素都属于一个函数。因此,此映射最终存储了在线程执行期间调用的所有函数的性能分析信息。此映射受临界区保护。
步骤5:堆栈清理
调用FindSymbol记录函数后,控制权返回到_penter。_penter中的下一步是堆栈清理。这分两步完成。
- 释放为寄存器预留的32字节空间。
- 弹出易失性寄存器。
_penter proc
push r11
push r10
push r9
push r8
push rax
push rdx
push rcx
; reserve space for 4 registers [ RCX,RDX,R8 and R9 ]
sub rsp,20h
; Get the return address of the function
mov rcx,rsp
mov rcx,qword ptr[rcx+58h]
sub rcx,5
; Call the exported function in dll which finds the name of the function
FindSymbol
; release the reserved space by moving stack pointer up by 32 bytes
add rsp,20h
; pop out the pushed registers
pop rcx
pop rdx
pop rax
pop r8
pop r9
pop r10
pop r11
ret
步骤6:填充_pexit
_pexit的汇编代码与_penter完全相同。唯一区别是从DLL调用的函数不同,这次是FindSymbol_1。此函数与FindSymbol相同,但它记录了调用_pexit函数的结束时间。FindSymbol_1在函数开始时记录时间,而FindSymbol在将ProfileInfo实例添加到映射之前测量时间。
步骤5:转储性能分析
性能分析信息在程序完成执行之前转储。这是通过DisplayProfileData函数完成的。当Dllmain以DLL_PROCESS_DETACH的原因调用时,会调用此函数。我们可以根据需要修改它。例如,我们可能希望在线程完成执行时显示该线程的所有性能分析信息。这可以在DLL_THREAD_DETACH情况下完成。只需找到完成执行的线程,并在g_mapProfileInfo中搜索该线程,然后仅显示该信息。
关于源代码
本文附加了3个示例程序。这两个程序都使用了名为SymbolServer.dll的DLL,该DLL负责从地址查找函数名、启动计时器、收集每个函数的性能分析信息等。
1.第一个程序是一个简单的控制台程序,名为ProfilerX64。
- 该程序使用一个名为ClientStaticLib的简单静态库,以显示从该库调用的函数也正在被分析。
- ProfilerX64和ClientStaticLib都使用/Gh和/GH开关进行编译。
2.第二个也是一个控制台应用程序,但它使用多个线程而不是一个。
3.一个简单的MFC应用程序
- 当在客户端区域左键单击时,它只会绘制一个圆圈。
- 当右键单击时,会执行一系列虚拟调用,并在最后显示一个消息框。
- 创建了一个控制台窗口来显示日志。
- 一些函数被过滤掉了,如GetRunTimeClass、OnPaint、CDocTemplate的成员函数、CView等。否则,您将看到大量这些条目。
- 函数名在进入和退出时在控制台窗口中转储。只需在客户端区域左键单击和右键单击。您会立即注意到控制台窗口中的条目。这种行为与用于ProfilerX64的SymbolServer.dll略有不同。
- 在应用程序结束/退出时,会显示所有性能分析信息,并等待用户输入一个值,然后才会关闭控制台窗口。这只是一个技巧,否则控制台窗口会随着应用程序一起关闭。
这些示例程序使用x64 Debug配置。因为我们已经提到,我们需要调试信息来获取SymbolServer.dll中的函数名。
关注点
我对汇编编程非常陌生。所以花了一些时间才理解堆栈管理、寄存器操作等。我花了一些时间来了解这些事情,并且确实很有趣。
未来工作
在下一个版本中,我计划记录每个函数的调用者(类调用堆栈)。例如,在main的情况下,我想在与main关联的ProfileInfo中记录_tmainCRTStartup。
也许我们可以在ProfileInfo结构中添加一个指向ProfileInfo的指针(类链表),它将指向调用函数的ProfileInfo。这可以为您提供调用函数的性能分析信息。
同样,我们可以在ProfileInfo中有一个ProfileInfo指针的向量,其中可以添加每个子函数的ProfileInfo。这可以为您提供特定函数调用的所有函数的性能分析信息。
致谢
我想感谢所有在我开始这项工作时直接或间接教导我汇编编程概念的人,感谢那些毫不延迟地回答我的问题的人,以及感谢那些关于性能分析器、汇编编程和x64的文章的作者。特别是,我想向MSDN成员Mike Danes和Crescens表示诚挚的感谢,感谢他们回答了我发布在论坛上的所有问题。
历史
2014年8月5日:文章发布。