C++钩取自己程序的函数

4

我想对我的应用程序进行性能分析,具体来说我希望记录程序启动后每个函数调用(忽略DLL中的函数)进入和退出的时间,即我想要一个简单的表格,类似于这样:

THREAD_ID FUNCTION_ADDRESS TIME EVENT_TYPE
5520      0xFF435360       0    ENTERED
5520      0xFF435ED3       25   ENTERED
5520      0xFF433550       40   ENTERED
5520      0xFF433550       50   EXITED
5520      0xFF433550       60   ENTERED
5520      0xFF433550       70   EXITED
5520      0xFF435ED3       82   EXITED
5520      0xFF435360       90   EXITED

对于一个程序,如果忽略编译器优化,它看起来像这样:

void test1(void)
{
   int a = 0;
   ++a;
}

void test(void)
{
    test1();
    test1();
}

void main(void)
{
    test();
}

我找不到现成的解决方案,最接近的是Microsofts VSPerfReport,但它只提供了每个函数花费的时间,没有进入和退出时间。
因此,我开始研究如何用一个简单的函数来挂钩所有我的函数,并生成上述表格所需的缓冲区。为了做到这一点,我想创建一个在main函数开始时调用的函数,可以遍历整个exe并修改CALL指令,使其调用我的挂钩函数。
那些像MinHook等的库对我来说都有点过头了,而且可能行不通,因为它是一个x64应用程序,我不想挂钩DLL函数。
因此,我考虑只修改每个CALL指令中的JMP指令,比如这个程序:
void main(void)
{
...asm prologue 
    test();
002375C9  call        test (235037h) 
}
...asm epilogue

这里的调用是针对 JMP 表格的:
@ILT+40(__set_errno):
0023502D  jmp         _set_errno (243D80h)  
@ILT+45(___crtGetEnvironmentStringsA):
00235032  jmp         __crtGetEnvironmentStringsA (239B10h)  
test:
00235037  jmp         test (237170h)  
@ILT+55(_wcstoul):
0023503C  jmp         wcstoul (27C5D0h)  
@ILT+60(__vsnprintf_s_l):

我希望遍历此表格并重新路由所有与应用程序的 .exe 中函数相关联的JMP到包含计时代码的钩子函数,然后返回到调用函数。
那么ILT代表什么?我假设是某种查找表,我该如何获得它?
这是否可能?我听说过IAT hooking,但在我看来,它只适用于hooking DLL。在这里,我忽略了退出,尽管使用另一个JMP替换RET指令可能会有所帮助?
感谢任何帮助。

如果你使用的是Linux,解决方案除了使用Valgrind之外,还可以使用LD_PRELOAD来加载一个共享对象,以钩住你的函数。我不知道在Windows上是否可行,你可能需要去查一下。这比替换汇编指令要容易得多。 - mfontanini
如果你的总体目标是找出需要修复哪些问题以使代码运行更快,那么有一种更快更有效的方法可以做到这一点。 - Mike Dunlavey
@Yakk & Mike:我非常清楚我们使用了大量的分析器和调试器来针对我们已经非常优化的代码进行各种硬件组件的分析。但这是为了获得不同的视角,我们有做这个的动机。 - user176168
@user176168:好的,那就是我想知道的。 - Mike Dunlavey
@Yakk:这是一个好词。我称之为“深度采样”,因为它以每个样本的信息为代价来交换样本数量。 - Mike Dunlavey
显示剩余5条评论
4个回答

2
你看过谷歌的性能分析工具了吗?相比于自己编写,你可能会发现它更容易修改。它确实会进行代码插入来执行其分析,所以至少,他们的注入框架对你有益处。
但是,对于像这样的东西,你大多数情况下都要避免时间开销,因此我建议通过地址跟踪,然后在完成分析时将地址转换为符号名称。钩子本身也可能是一个艰巨的任务,我建议制作一个全功能包装器,不要改变函数的输入或输出,而是重定向调用点。

那么ILT代表什么,我假设是某种查找表,我该如何获取它呢?

ILT代表Import Lookup Table,如果您计划对内部函数进行分析,它并没有太大的用处。要获取它需要深入探索平台模块格式(PE、ELF、MACH-O)的内部。

1

0
struct my_time_t;
my_time_t get_current_time(); // may be asm


struct timestamp;
struct timer_buffer {
  std::unique_ptr<timestamp[]> big_buffer;
  size_t buffer_size;
  size_t current_index;
  size_t written;
  buffer( size_t size ): big_buffer( new timestamp[size] ), buffer_size(size), current_index(0), written(0) {}
  void append( timestamp const& t ) {
    big_buffer[current_index] = t;
    ++current_index;
    ++written;
    current_index = current_index % buffer_size;
  }
};
struct timestamp {
  static timer_buffer* buff;
  timestamp const* loc;
  my_time_t time;
  const char* filename;
  size_t linenum;
  timestamp( my_time_t t, const char* f=nullptr, size_t l = 0 ):
    loc(this), time(t), f(filename), l(linenum)
  {
    go();
  }
  void go() {
    buff->append(*this);
  }
};
struct scoped_timestamp:timestamp {
  scoped_timestamp( my_time_t t, const char* f=nullptr, size_t l = 0 ):
    timestamp(t, f, l)
  {}
  ~scoped_timestamp() {
    go();
  }
};
#define TIMESTAMP_SCOPE( NAME ) scoped_timestamp NAME(get_current_time(), __FILE__, __LINE__);
#define TIMESTAMP_SPOT() do{timestamp _(get_current_time(), __FILE__, __LINE__);}while(false)

在某个地方创建timestamp::buff。确保buff足够大。编写一个快速高效的get_current_time()函数。

在你认为有问题的函数开头插入TIMESTAMP_SCOPE(_)

在你认为需要花费时间的位置之间插入TIMESTAMP_SPOT();

在关闭之前对timer_buffer进行一些后处理--将其写入磁盘或其他设备。注意观察written>current_index,如果是这样,说明你已经包装了缓冲区。请注意,上述代码都不包含任何分支,因此应该相对性能友好(除了不断将由buff拥有的数组移动到缓存中)。

loc存在的目的是为了相对容易地找到创建/销毁对(因为它的二进制值跟踪堆栈的值!),因此您可以在事后分析缓冲区,查看哪些函数调用花费了太长时间。组合一个可视化工具并不难,我曾经看到过类似上述代码用于检测视频流驱动程序代码中的毫秒级定时故障和卡顿。

current_index开始向后分析,寻找一对配对,直到遇到0(或者如果written!= current_index,则一直寻找直到回绕到current_index+1)。恢复调用图(如果需要)应该不难。

大多数内容都被剥离了,只是对每个timestamp使用唯一的标记,可以减少缓冲区的大小,但可能会使重构调用图变得更加困难。

是的,这不是自动插装。但您的代码中表现不佳的部分将是相对较小的部分。因此,从上述内容开始插装,我猜您将比通过反汇编编译器的二进制输出并且操作跳转表效果更好。


0
在Linux上,您可以使用gprof(1)来获取该数据。但请注意Bentley在他的{{link1:“Programming Pearls”}}中对性能的描述。它的第二部分是他的“编写高效程序”的精华(可惜已经绝版),非常详细地讨论了如何(更重要的是何时)优化代码。

如果您向学生推荐使用 gprof,这里有一些关于它的注释,并请查看这个Bentley讲座的第35页。 - Mike Dunlavey
我拥有这本书并在十年前阅读过它。它在当时很不错,但现在已经有些过时了。介绍哈希或二进制搜索的奇妙之处对于大多数初级程序员来说并没有什么帮助。它没有考虑到现代CPU和GPU架构,例如乱序执行、硬件预取、缓存行、多个缓存、SIMD、多个处理器核心等。并行性在附录中只被提及一次:“程序应该被结构化以尽可能地利用底层硬件中的并行性”。我就不再多说了。 - user176168
它仍然与主题相关。当然,技术不断进步(多核处理器、非一致性内存访问、图形处理单元作为处理工具、高级语言隐藏复杂数据结构的细节、庞大的内存),但基本原则保持不变:在深入之前进行测量,尝试使用更高级的方法(新的数据结构、不同的软件组织)而不是低级别的技巧(使用位操作替代算术运算),检查编译器是否已经进行了改进,仔细考虑您的更改对可维护性的影响。别让我开始谈“资深程序员”了。 - vonbrand

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