《开局一个二进制,从零开始的 LoongArch 指令集推导》——第七回 序与跋(三)

本 LoongArch 指令集研究工作在百度贴吧龙芯吧同步连载。

本研究中涉及的逆向工程仅出于学习、研究目的。本研究工作未得到任何龙芯、麒麟等软硬件厂商的任何形式帮助。

本研究属于个人行为,与本人雇主或任何其他主体无关。

到上一回结束,我们对下面这个函数已经有了初步的理解:

void
PyNode_ListTree(node *n)
{
    listnode(stdout, n);
}
0000000000053368 <PyNode_ListTree>:
   53368:       02ff8063        addi    sp, sp, -32
   5336c:       29c06061
   53370:       29c04076
   53374:       02c08076        addi    r22, sp, 32
   53378:       29ffa2c4
   5337c:       1c0076ac
   53380:       28dab18c
   53384:       2600018c
   53388:       28ffa2c5
   5338c:       00150184
   53390:       54001800
   53394:       03400000
   53398:       28c06061
   5339c:       28c04076
   533a0:       02c08063        addi    sp, sp, 32
   533a4:       4c000020        ret

在继续之前,先介绍一些背景吧。

寄存器分两种,调用方保存(caller-saved)和被调用方保存(callee-saved);调用方保存的寄存器也叫临时(temporary)寄存器,被调用方保存的寄存器也叫被保存(saved)寄存器。那么为何从这个角度划分呢?

很多(不是全部)函数都会用到一些临时空间,而比较简单的函数往往不需要很多,只用几个寄存器就够了;因为临时空间的使用频率一般会比较高,而且用完就丢,人们自然不希望每次调用函数前,每个值都要无条件推到栈上保存,返回了还要弹出来。但与此同时,没被保存的寄存器就都可能被覆盖,如果大部分都给用作临时空间了,自己一直要用到的一些量还是不得不往栈上走一遭。于是人们便平衡了两种用途的寄存器数量,并且约定了各自的寄存器号范围。

这一部分寄存器,每个函数都可以随便用,覆盖之前不用保存;相应的,如果这个寄存器的值我之后还要用,就由我在调用你之前负责存起来,要用的时候再自己从栈上找回来。所以我保存的寄存器就是临时寄存器。

那一部分寄存器,约定是你返回之后,里面的值肯定不会变,这样我数据量小的话就不用去访问内存了;如果你要用,就由你负责在覆盖之前存起来,你返回前恢复原样就行。所以你保存的寄存器就是调用后也能保住内容的 saved 寄存器。

这也就是许多 RISC 架构的 sX tX 寄存器序列的由来。特别地,传参和返回值占用的空间,性质上也属于临时空间,因此 aX(argument)和 vX(value)两个序列行为上和临时寄存器一样。有的架构如 AArch64 和 RISC-V 复用了 aX 的几个寄存器用作返回值,不像 MIPS 有 v0 v1 专门用来放返回值,这也是现代常见的做法了。

还有一些特殊寄存器,比如栈指针 sp(stack pointer),帧指针 fp(frame pointer),返回地址寄存器 ra(return address)。sp fp 总是被调用方保存,ra 总是调用方保存。

ra 寄存器往往会被子程序调用指令隐式(例如 MIPS 的 jal jalr)或显式(RISC-V 的 jalr)修改,因此一个函数如果不在调用子程序前自己保存 ra,子程序就不可能看到上一个 ra 了,多层函数调用这么一件最基本的事情就无法实现了。

还有一个顺带的好处,只要不调用任何子程序,ra 就不会变,就不需要保存。这是常见的“叶子函数”优化。

至于 sp fp,道理更简单了:因为调用方不知道被调用方需要多大的栈空间(编译时往往是看不到被调用函数具体实现的,何况调用的函数本身也可以是动态的——函数指针),所以它们的调节只能由被调用方自己完成了。另外,帧指针只对可能动态调整栈空间占用的函数有用,或者用于调试,所以打开优化的二进制往往不会维护帧指针,它的 fp 其实就被当作多一个 saved register 来用了。这种往往就很难调试,无法区分栈上哪些空间是哪一层函数调用使用的了。

那么终于可以回到开头的汇编代码,r3 已经用 sp 称呼了,进来先分配 32 字节的栈空间——接下来似乎 r22 又回到了刚进入函数时候的 sp 取值!

这是没开优化的 Python 代码,那么 r22 就是帧指针!

有了这个知识,应该进出函数前的相同数量的 29xxxxxx 28xxxxxx 就能解析了。敬请期待下回分解 ;-)