如何获取调用堆栈回溯?(嵌入式系统,无库支持)

20
我希望我的异常处理程序和调试函数能够打印调用堆栈(call stack)的回溯信息,就像glibc库中的backtrace()函数一样。不幸的是,我的C库(Newlib)没有提供这样的功能。
我有类似以下的代码:
#include <unwind.h> // GCC's internal unwinder, part of libgcc
_Unwind_Reason_Code trace_fcn(_Unwind_Context *ctx, void *d)
{
    int *depth = (int*)d;
    printf("\t#%d: program counter at %08x\n", *depth, _Unwind_GetIP(ctx));
    (*depth)++;
    return _URC_NO_REASON;
}

void print_backtrace_here()
{
    int depth = 0;
    _Unwind_Backtrace(&trace_fcn, &depth);
}

基本上它是工作的,但生成的跟踪信息并不总是完整的。例如,如果我执行以下操作:

int func3() { print_backtrace_here(); return 0; }
int func2() { return func3(); }
int func1() { return func2(); }
int main()  { return func1(); }
回溯只显示了 func3() 和 main()。(这显然是个玩具示例,但我已经检查过汇编代码并确认这些函数都完整存在,没有被优化掉或内联)。
更新:我在旧的 ARM7 系统上尝试了这个回溯代码,但使用了相同(或至少尽可能等价)的编译器选项和链接脚本,它打印出了一个正确、完整的回溯(即 func1 和 func2 没有丢失),而且甚至能够回溯到初始化引导代码之前的 main。因此,问题不是链接器脚本或编译器选项造成的。(还从汇编代码中确认,在这个 ARM7 测试中没有使用帧指针)。
该代码是使用 -fomit-frame-pointer 编译的,但我的平台(bare metal ARM Cortex M3)定义了不使用帧指针的 ABI。(先前版本的系统在 ARM7 上使用了旧的 APCS ABI 强制使用堆栈帧和帧指针,并且像这里一样的回溯完全正常)。
整个系统都使用 -fexception 编译,这确保了 _Unwind 使用的必要元数据包含在 ELF 文件中。(_Unwind 是为异常处理而设计的吧)
所以,我的问题是: 有没有使用 GCC 在嵌入式系统中获取可靠回溯的“标准”、公认的方法?
如果必要,我不介意在链接器脚本和 crt0 代码中进行混淆,但不想对工具链本身进行任何更改。
谢谢!

许多重复的问题,包括https://dev59.com/yHVD5IYBdhLWcg3wI36L。 - anon
4
尼尔:你读了问题吗?(除了标题和粗体印刷的行之外?)他得到了一个回溯,但缺少一些被调用的函数。 - IanH
这对于在Android NDK项目中获取回溯打印非常有帮助。 - chrisvarnz
2
你的问题是否已经得到解决? - tothphu
6个回答

10
为此,您需要使用-funwind-tables-fasynchronous-unwind-tables。在某些目标中,这是必需的,以便_Unwind_Backtrace正常工作!

2
我不知道这个选项是干什么的,但在链接时你可能还需要指定--no-merge-exidx-entries。http://old.nabble.com/Stack-backtrace-for-ARM-Thumb-td29264138.html - Justin L.
1
@JustinL. - 链接目前已失效,顺便说一下。 - 500 - Internal Server Error

9
由于ARM平台不使用帧指针,因此您无法确定堆栈帧的大小,也不能简单地通过R14中的单个返回值来展开堆栈。
在调查没有调试符号的崩溃时,我们只需转储整个堆栈,并查找最接近指令范围内每个项的符号。它确实会产生大量误报,但仍然非常有用于调查崩溃。
如果您运行纯ELF可执行文件,则可以从发布的可执行文件中分离调试符号。 gdb然后可以帮助您从标准unix核心转储中找出发生了什么。

你可以通过使用反汇编的可执行文件手动重构堆栈帧来减少误报; 查看每个函数的前几条指令以计算堆叠寄存器的数量,并对堆栈指针进行任何进一步的调整。 - Mike Seymour
挑剔一点:有些ARM平台确实使用帧指针(通常是r11)。但这在这里并不重要,因为提问者说明他的平台没有使用。 - Mike Seymour
Mike:是的,我可以自己做...但肯定有一些代码或库可以利用已经完成了这个工作吧?!在异常的情况下,每个可能的堆栈帧都必须包含必要的元数据(至少是大小)以展开堆栈。因此,鉴于异常处理起作用,为什么gcc自己的展开程序不能为我完成这项工作呢? - hugov
@Mike Seymour - 从技术上讲,ARM汇编器甚至没有栈的概念。我们最接近的是LDM和STM指令。因此,您可以自由地以任何方式实现堆栈。用于大多数标准ARM ABI的ARM过程调用不支持帧指针,但除了兼容性外,没有任何东西会阻止您使用帧指针。 - doron
@deus:确实,尽管Thumb有pushpop指令,假定具有r13作为堆栈指针的完全下降堆栈,因此堆栈的概念已经滑入汇编语言中。当前ABI没有帧指针的概念,但早期的ABI有变体,以允许在调试信息不能可靠地进行时解开, - Mike Seymour
显示剩余2条评论

7

gcc能够进行优化。在func1()和func2()中,它不会调用func2()/func3() - 相反,它跳转到func2()/func3(),因此func3()可以立即返回到main()。

在您的情况下,func1()和func2()不需要设置堆栈帧,但是如果它们确实需要(例如用于局部变量),如果函数调用是最后一条指令,gcc仍然可以进行优化 - 然后在跳转到func3()之前清理堆栈。

查看生成的汇编代码以了解详情。


编辑/更新:

要验证这是原因,请在函数调用之后执行一些编译器无法重新排序的操作(例如使用返回值)。或者只需尝试使用-O0编译。


他说函数已经存在(没有内联),但他没有说他是否检查了这些函数是否被调用或跳转。 - IanH
@DeadMG:这个踩票肯定很严厉。当编译为ARM时,尾调用通常会像这样进行优化,而这种优化将给出观察到的结果。 - Mike Seymour
OP特别说明他已经检查了反汇编器。 - Puppy
1
@DeadMG:他说他已经检查了函数是否被调用而不是内联,但他可能错过了以分支而不是返回结尾的函数。这不是你会注意到的,除非你仔细阅读每个指令。当然,你的投票权由你自己决定如何处理。 - Mike Seymour
即使查看反汇编代码,如果您不了解此优化,您很容易错过调用或跳转。我仍然认为这是问题所在-另一个答案很有意思,但它没有解释为什么在回溯中只有func3()和main()(而不是仅func3()和func2())。 - IanH
2
澄清一下:原帖中的简化玩具代码可能已经进行了返回调用/跳转优化,但在实际代码中,调用两侧都有东西无法(我已验证它们不能)被优化掉。每个函数开头和结尾都有 push/pop,链中的下一个函数是通过 blx 指令(Thumb2)调用的。 - hugov

3
有些编译器,例如GCC会优化函数调用,就像你在例子中提到的那样。对于代码块的操作来说,在调用链中不需要存储中间返回指针。从func3()返回到main()是完全可以的,因为中间函数除了调用另一个函数外没有做任何额外的工作。
这与代码消除不同(实际上中间函数可能会被完全优化掉),并且单独的编译器参数可能控制这种类型的优化。
如果您使用GCC,请尝试-fno-optimize-sibling-calls 另一个方便的GCC选项是-mno-sched-prolog,它可以防止函数序言中的指令重新排序,如果您想像这里一样逐字节解析代码,则这非常重要: http://www.kegel.com/stackcheck/checkstack-pl.txt

1
这种方法有些取巧,但考虑到所需的代码/内存空间量,我发现它足够好用:
假设您正在使用ARM THUMB模式,请使用以下选项进行编译:
-mtpcs-frame -mtpcs-leaf-frame  -fno-omit-frame-pointer

以下函数用于检索调用堆栈。有关更多信息,请参阅注释:
/*
 * This should be compiled with:
 *  -mtpcs-frame -mtpcs-leaf-frame  -fno-omit-frame-pointer
 *
 *  With these options, the Stack pointer is automatically pushed to the stack
 *  at the beginning of each function.
 *
 *  This function basically iterates through the current stack finding the following combination of values:
 *  - <Frame Address>
 *  - <Link Address>
 *
 *  This combination will occur for each function in the call stack
 */
static void backtrace(uint32_t *caller_list, const uint32_t *caller_list_end, const uint32_t *stack_pointer)
{
    uint32_t previous_frame_address = (uint32_t)stack_pointer;
    uint32_t stack_entry_counter = 0;

    // be sure to clear the caller_list buffer
    memset(caller_list, 0, caller_list_end-caller_list);

    // loop until the buffer is full
    while(caller_list < caller_list_end)
    {
        // Attempt to obtain next stack pointer
        // The link address should come immediately after
        const uint32_t possible_frame_address = *stack_pointer;
        const uint32_t possible_link_address = *(stack_pointer+1);

        // Have we searched past the allowable size of a given stack?
        if(stack_entry_counter > PLATFORM_MAX_STACK_SIZE/4)
        {
            // yes, so just quite
            break;
        }
        // Next check that the frame addresss (i.e. stack pointer for the function)
        // and Link address are within an acceptable range
        else if((possible_frame_address > previous_frame_address) &&
                ((possible_frame_address < previous_frame_address + PLATFORM_MAX_STACK_SIZE)) &&
               ((possible_link_address  & 0x01) != 0) && // in THUMB mode the address will be odd
                (possible_link_address > PLATFORM_CODE_SPACE_START_ADDRESS &&
                 possible_link_address < PLATFORM_CODE_SPACE_END_ADDRESS))
        {
            // We found two acceptable values

            // Store the link address
            *caller_list++ = possible_link_address;

            // Update the book-keeping registers for the next search
            previous_frame_address = possible_frame_address;
            stack_pointer = (uint32_t*)(possible_frame_address + 4);
            stack_entry_counter = 0;
        }
        else
        {
            // Keep iterating through the stack until be find an acceptable combination
            ++stack_pointer;
            ++stack_entry_counter;
        }
    }

}

你需要更新平台的 #define。
然后调用以下函数,以填充缓冲区中的当前调用堆栈:
uint32_t callers[8];
uint32_t sp_reg;
__ASM volatile ("mov %0, sp" : "=r" (sp_reg) );
backtrace(callers, &callers[8], (uint32_t*)sp_reg);

虽然这种方法有些粗糙,但我发现它非常有效。 缓冲区将填充调用栈中每个函数调用的链接地址。


有点hackish,但还算有效。我可以使用这种方法获取2个堆栈帧。 _Unwind_Backtrace()libunwind 给了我所有4个帧。 - Alexei Khlebnikov

0
你的可执行文件是否包含使用 -g 选项编译时生成的调试信息?我认为这是在没有帧指针的情况下获取完整堆栈跟踪所必需的。
您可能需要使用 -gdwarf-2 确保使用包含展开信息的格式。

可能是可以的,尽管我非常确定(99.9%)DWARF信息实际上并没有进入编程到闪存中的二进制映像。我该如何检查? - hugov

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