gdb 如何重建 C++ 的堆栈跟踪?
我将整个问题分成了几个较小的问题:
- GDB 能够使用哪些不同的算法来重建堆栈跟踪?
- 每个堆栈跟踪重建算法如何在高层工作?优点和缺点?
- 编译器需要在程序中提供什么样的元信息才能使每个堆栈跟踪重建算法发挥作用?
- 还有相应的 g++ 编译器开关来启用/禁用特定算法?
I have divided the whole question into smaller ones:
- What kind of different algorithms GDB is capable to use to reconstruct stacktraces?
- How each of the stacktrace reconstruction algorithm works at high level? Advantages and disadvantages?
- What kind of meta-information compiler needs to provide in program for each stacktrace reconstruction algorithm to work?
- And also corresponding g++ compiler switches that enable/disable particular algorithm?
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论
评论(1)
说到伪代码,您可以将堆栈称为“打包堆栈帧的数组”,其中每个堆栈帧都是可变大小的数据结构,您可以这样表达:
问题是每个函数都有不同的
- 框架大小不同。
编译器知道帧大小,并且如果创建调试信息通常会发出这些帧大小作为其中的一部分。然后调试器需要做的就是找到最后一个程序计数器,在符号表中查找函数,然后使用该名称在调试信息中查找帧大小。将其添加到堆栈指针中,您就可以到达下一帧的开头。
如果使用此方法,则不需要帧链接,即使使用
-fomit-frame-pointer
,回溯也能正常工作。另一方面,如果有帧链接,则迭代堆栈只是遵循链表 - 因为新堆栈帧中的每个帧指针都由函数序言代码初始化以指向前一个。如果您既没有帧大小信息也没有帧指针,但仍然有符号表,那么您还可以通过一些逆向工程执行回溯,以从实际二进制文件计算帧大小。从程序计数器开始,在符号表中查找它所属的函数,然后从头开始反汇编该函数。隔离函数开头和程序计数器之间实际修改堆栈指针的所有操作(将任何内容写入堆栈和/或分配堆栈空间)。它计算当前函数的帧大小,因此从堆栈指针中减去它,并且您应该(在大多数体系结构上)找到在进入函数之前写入堆栈的最后一个字 - 这通常是调用者的返回地址。必要时再次重申。
最后,您可以对堆栈的内容进行启发式分析 - 隔离堆栈中位于进程地址空间的可执行映射段内的所有字(因此可能是函数偏移量,也称为返回地址),并播放什么-如果游戏查找内存,则反汇编那里的指令,看看它是否实际上是某种调用指令,如果是的话,是否真的调用了“下一个”,以及是否可以从中构造一个不间断的调用序列。即使二进制文件被完全剥离,这在一定程度上也有效(尽管在这种情况下您所能得到的只是返回地址列表)。我不认为 GDB 采用这种技术,但一些嵌入式低级调试器采用了这种技术。在 x86 上,由于指令长度不同,这非常困难,因为您无法轻松地通过指令流“后退”,但在 RISC 上,指令长度是固定的,例如在 ARM 上,这要简单得多。
有些漏洞有时会导致这些算法的简单甚至复杂/详尽的实现失败,例如尾递归函数、内联代码等。 gdb 源代码可能会给您更多的想法:
https://sourceware.org/git/?p=binutils-gdb.git;a=blob;f=gdb/frame.c
GDB 采用了多种此类技术。
Speaking Pseudocode, you could call the stack "an array of packed stack frames", where every stack frame is a data structure of variable size you could express like:
Problem is that every function has a different
<N>
- frame sizes vary.The compiler knows frame sizes, and if creating debugging information will usually emit these as part of that. All the debugger then needs to do is to locate the last program counter, look up the function in the symbol table, then use that name to look up the framesize in the debugging information. Add that to the stackpointer and you get to the beginning of the next frame.
If using this method you don't require frame linkage, and backtracing will work just fine even if you use
-fomit-frame-pointer
. On the other hand, if you have frame linkage, then iterating the stack is just following a linked list - because every framepointer in a new stackframe is initialized by the function prologue code to point to the previous one.If you have neither frame size information nor framepointers, but still a symbol table, then you can also perform backtracing by a bit of reverse engineering to calculate the framesizes from the actual binary. Start with the program counter, look up the function it belongs to in the symbol table, and then disassemble the function from the start. Isolate all operations between the beginning of the function and the program counter that actually modify the stackpointer (write anything to the stack and/or allocate stackspace). That calculates the frame size for the current function, so subtract that from the stackpointer, and you should (on most architectures) find the last word written to the stack before the function was entered - which is usually the return address into the caller. Re-iterate as necessary.
Finally, you can perform a heuristic analysis of the contents of the stack - isolate all words in the stack that are within executably-mapped segments of the process address space (and thereby could be function offsets aka return addresses), and play a what-if game looking up the memory, disassembling the instruction there and see if it actually is a call instruction of sort, if so whether that really called the 'next' and if you can construct an uninterrupted call sequence from that. This works to a degree even if the binary is completely stripped (although all you could get in that case is a list of return addresses). I don't think GDB employs this technique, but some embedded lowlevel debuggers do. On x86, due to the varying instruction lengths, this is terribly difficult to do because you can't easily "step back" through an instruction stream, but on RISC, where instruction lengths are fixed, e.g. on ARM, this is much simpler.
There are some holes that make simple or even complex/exhaustive implementations of these algorithms fall over sometimes, like tail-recursive functions, inlined code, and so on. The gdb sourcecode might give you some more ideas:
https://sourceware.org/git/?p=binutils-gdb.git;a=blob;f=gdb/frame.c
GDB employs a variety of such techniques.