嵌入式系统那些事——aarch64 backtrace嵌入式汇编实现

0 背景

        在aarch64嵌入式应用开发中,经常会遇到段错误(segmentation fault),但是通常情况下系统报错后直接退出,没有异常调用打印信息,定位出错原因十分困难。经确认,该问题是由于没有设置捕获段错误,并调用backtrace打印异常调用栈,笔者实现该异常捕获处理方案后,发现仍然没有异常调用栈输出。经进一步定位发现,该嵌入式设备上使用的glibc库版本过低,并不支持aarch64的backtrace功能。

        笔者找到glibc的最新开源版本glibc2.36(2022年8月发布),其中包含了对aarch64 backtrace功能支持。面对这种情况,能想到的第一种解决方案是将glibc升级到最新的版本,但是这样除了要交叉编译glibc以外,所有的组件,包括操作系统和应用程序都要一起交叉编译更新,复杂度很大。还有一种解决的思路是,将其中backtrace相关的功能移植过来,通过实验发现,这个移植工作量同样很大。于是笔者萌生了基于backtrace的原理和嵌入式汇编自己去写一套aarch64上可以使用的接口。

1 backtrace实现原理

        backtrace的实现本质上是基于最基本的函数调用关系的回溯过程。在我们的代码中,函数是一级一级调用下去的,每个函数都有自己的母函数或者子函数,这种调用关系就像阶梯一样,形成一个调用栈,出现问题后,我们就可以从出现问题的函数起,恢复出调用它的整个函数过程,这样就可以知道是哪一段流程调用出现的问题。函数之间的调用关系是由系统维护的,作为程序员通常不需要关注,但是此处想模拟函数调用栈回溯的过程,就需要对这个函数调用栈的基本原理有所了解。

        如下图所示,展示了调用者和被调用者的关系。这个关系用栈来存储,我们知道栈最基本的原理是先进后出,这样就可以保证被调用者先执行返回结果给调用者。在函数调用栈的设计中,栈是由高地址往低地址增长的,这个在笔者前面的文章【1】有介绍,当调用关系发生时,调用者会首先被压入栈中,被调用者后被压入栈中。被调用者压入的同时,调用者的返回值地址和调用者下一条将要执行的指令地址会被存储在寄存器中,当被调用者执行完成后,就可以基于该返回地址回到调用者的栈顶,然后继续执行调用者的下一条指令。

嵌入式系统那些事——aarch64 backtrace嵌入式汇编实现_第1张图片

        上图中,aarch64存储返回地址和下一条要执行的指令地址的寄存器分别是FR(X29)和LR(X30)。这两个寄存器可以通过反汇编aarch64编译出的bin文件得到,如下图所示,在汇编函数的头两行。需要注意的是不同的体系结构这两个寄存器是不一样的,如arm32就可能是另外的寄存器,需要根据实际情况,反汇编得到。如果想要验证这两个寄存器的值,可以在c代码中嵌入汇编语言,在函数开始的地方,打印出这两个寄存器的值,后续笔者就是通过这种方式找到函数之间的回溯规律的。

嵌入式系统那些事——aarch64 backtrace嵌入式汇编实现_第2张图片

2 aarch64 backtrace代码实现

        以如下图最左侧所示的函数调用关系为例,图中函数的调用关系依次为:main=>func_a=>func_b=>func_c,假设在函数func_c中访问了空指针导致段错误,那如何恢复出整个异常函数调用栈呢?

        首先将系统默认的段错误直接退出应用程序改为通过捕获异常信号的方式接管。即捕获SIGSEGV异常信号,并注册回调处理函数为print_trace,在其中调用aarch64的这个红色标注的backtrace_arm,该核心代码我们稍后介绍,此处得到函数的异常调用栈为下一条执行的指令地址,而不是当前执行的函数的入口地址,感兴趣的读者可以在x86的虚拟机上实验glibc中的backtrace库函数,看一下是不是这样的规律。最后就可以直接使用backtrace_symbols库函数解析异常调用栈地址为对应的函数名。

嵌入式系统那些事——aarch64 backtrace嵌入式汇编实现_第3张图片

        为了更深刻地理解函数异常调用栈的回溯原理,我们首先可以将x29和x30寄存器地址和存储的值打印出来。从上图中可以看到被调用者的fp(x29)存储的值是调用者的fp的地址,依次递归,直到fp的地址为0。如果将lr(x30)存储的值在反汇编的代码中搜索,刚好可以对应到被调用者返回后下一条要执行指令地址地址。有了这两个规律,我们就可以通过不断循环访问x29寄存器的值,直到0,回溯整个调用栈,如上图最右边所示;在回溯到每个调用者的时候,通过减去2就可以得到lr寄存器的地址,不需要直接去访问x30寄存器,此时访问的话也会出错,因为我们并没有真正去弹栈,而是模拟弹栈回溯。为何要减去2,就可以得到lr的地址?因为入栈的时候下一条将要执行的指令地址寄存器x30的值先被压入,而后返回地址寄存器x29的值才被压栈,根据栈是由高地址往低地址增长的,所以回溯的时候,地址要做减法。至于减去2,则是因为我们是aarch64的体系结构,一个地址需要两个32位的存储空间,上图打印的fp地址虽然显示32bits,实际上是64bits。,这里是很容易出错的。

        核心的嵌入式汇编代码实现如下图所示。代码实现的逻辑上文已经说明此处不再重复。需要注意的点是如果不熟悉访问寄存器的指令可以反汇编得到。下图中最后的buffer中存储每次栈回溯时的lr寄存器的值,即下一条将要执行的指令。

嵌入式系统那些事——aarch64 backtrace嵌入式汇编实现_第4张图片

        最终的结果如下图所示,可以得到异常调用栈回溯的下一条指令组成的调用栈,最后输入到backtrace_symbols函数中,就可以转换出函数名。

嵌入式系统那些事——aarch64 backtrace嵌入式汇编实现_第5张图片

3 小结

        本文带着读者从函数调用栈的原理入手,以自己实战的backtrace_arm为例,详述了整个实现过程。感兴趣的读者可以自己动手实验一下,相信有更大的收获。

你可能感兴趣的:(万物互联之系统架构篇,c语言,arm,嵌入式硬件,中间件)