X86-64和ARM64用户栈的结构 (3) ---_start到__libc_start_main

1 x86-64

本节主要核心是介绍x86-64体系结构下的_start函数,该函数是由x86-64汇编写成;调用__libc_start_main函数向其传递参数。所以须要先了解一些x86-64的栈帧结构、寄存器、以及参数传递规则。git

1.1 栈帧(Stack Frame)

Linux使用System V Application Binary Interface的函数调用规则。在《System V Applocation Binary Interface》中3.2.2 The Stack Frame中写道:
In addition to registers, each function has a frame on the run-time stack. This stack grows downwards from high addresses. Figure 3.3 shows the stack organization. The end of the input argument area shall be aligned on a 16 (32 or 64, if __m256 or __m512 is passed on stack) byte boundary. In other words, the value (%rsp + 8) is always a multiple of 16 (32 or 64) when control is transferred to the function entry point. The stack pointer, %rsp, always points to the end of the latest allocated stack frame.
X86-64和ARM64用户栈的结构 (3) ---_start到__libc_start_maingithub

在输入参数的结尾处rsp必须对齐到16字节,当调用函数时,首先rsp会减8,rip会压栈,在栈中占8个字节,而后rip指向另外一个函数的entry point,也即控制转移到了函数的entry point。因为rip压栈了,rsp+8应该是16字节对齐。架构

至于为何须要16字节对齐?查了一些资料发现和Sreaming SIMD Extensions(SSE)有关,它是一组CPU指令,用于像信号处理、科学计算或者3D图形计算同样的应用(SSE入门)。SIMD 也是几个单词的首写字母组成的: Single Instruction, Multiple Data。 一个指令发出后,同一时刻被放到不一样的数据上执行。16个128bit XMM寄存器能够被SSE指令操控,SSE利用这些寄存器能够同时作多个数据的运算,从而加快运算速度。可是数据被装进XMM寄存器时,要求数据的地址须要16字节对齐,而数据常常会在栈上分配,所以只有要求栈以16字节对齐,才能更好的支持数据的16字节对齐。ide

1.2 寄存器和参数传递(Parameter Passing)

X86-64的寄存器相对于X86有扩展,主要不一样体如今:函数

  • 通用寄存器:X86-64有16个64bit通用寄存器
  • 状态寄存器:1个64bit状态寄存器RFLAGS,仅仅低32bit被使用
  • 指令寄存器:1个64bit指令寄存器RIP
  • MMX寄存器:8个64bitMMX寄存器,16个128bitXMM寄存器。当使用这些寄存器时,数据的地址必须对齐到64bit、128bit。

16个64bit寄存器 为:RAX,RBX,RCX,RDX,RDI,RSI,RBP,RSP,R8,R9,R10,R11,R12,R13,R14,R15
在X86-64架构的处理器上,Windows和Linux的函数调用规则不同。post

  • rax 做为函数返回值使用。
  • rsp 栈指针寄存器,指向栈顶。
  • rdi,rsi,rdx,rcx,r8,r9 用做函数参数,依次对应第1参数,第2参数...
  • rbx,rbp,r12,r13,r14,r15 用做数据存储,遵循被调用者(callee)使用规则,简单说就是随便用,调用子函数以前要备份它,以防他被修改
  • r10,r11 用做数据存储,遵循调用者(caller)使用规则,简单说就是使用以前要先保存原值当参数的数目小于7个时,使用rdi,rsi, rdx, rcx, r8 and r9传递参数,大于等于7个时使用stack传参数。具体的规则见《System V Applocation Binary Interface》中3.2.3 Parameter Passing
    X86-64和ARM64用户栈的结构 (3) ---_start到__libc_start_main

1.3 _start函数

0000000000000540 <_start>:
 540:   31 ed                 xor    %ebp,%ebp
 542:   49 89 d1              mov    %rdx,%r9
 545:   5e                    pop    %rsi
 546:   48 89 e2              mov    %rsp,%rdx
 549:   48 83 e4 f0           and    $0xfffffffffffffff0,%rsp
 54d:   50                    push   %rax
 54e:   54                    push   %rsp
 54f:   4c 8d 05 da 02 00 00  lea    0x2da(%rip),%r8      # 830 <__libc_csu_fini>
 556:   48 8d 0d 63 02 00 00  lea    0x263(%rip),%rcx     # 7c0 <__libc_csu_init>
 55d:   48 8d 3d 2c 02 00 00  lea    0x22c(%rip),%rdi     # 790 <main>
 564:   ff 15 76 0a 20 00     callq  *0x200a76(%rip)      # 200fe0 <__libc_start_main@GLIBC_2.2.5>
 56a:   f4                    hlt
 56b:   0f 1f 44 00 00        nopl   0x0(%rax,%rax,1)

跟据上述汇编,其实也就作了一件事,调用__libc_start_main函数,并向其传递了7个参数:操作系统

  • r9传递 rdx
  • r8传递 __libc_csu_fini
  • rcx传递 __libc_csu_init
  • rdx传递 argv
  • rsi传递 argc
  • rdi传递 main
  • 栈传递 rsp的值

上述汇编有几句比较晦涩:.net

  • and $0xfffffffffffffff0,%rsp的目的是使rsp对齐到16字节。
  • push %rax 为了在调用__libc_start_main以前,帮助rsp对齐到16字节,%rax入栈无其它意义。显然,这一句执行后,rsp尚未对齐到16字节,下一句汇编执行后就将对齐到16字节。
  • push %rsp, rsp的值入栈,这时将rsp的值传递给__libc_start_main函数,且使rsp对齐到16字节。

执行_start的第一条指令时,rsp的值是多少呢?谁设置的呢?rsp的值是bprm->p,Linux内核设置的,在上面的内容中有介绍。下图结合了Linux Kernel和_start设置的栈。其实_start来自glibc,在x86-64平台上,能够在文件sysdeps/x86_64/start.S中找到代码。这段代码的目的很单纯,只是给函数__libc_start_main准备参数。函数__libc_start_main一样来自glibc,它定义在文件csu/libc-start.c中。
X86-64和ARM64用户栈的结构 (3) ---_start到__libc_start_main
函数__libc_start_main的原型以下:设计

int __libc_start_main(
         (int (*main) (int, char**, char**),
         int argc,
         char **argv,
         __typeof (main) init,
         void (*fini) (void),
         void (*rtld_fini) (void),
         void* stack_end)

2 ARM64

和x86-64节的套路相似,先了解一些ARM64的栈帧结构、寄存器、以及参数传递规则。3d

2.1 栈帧

下图取自<Procedure Call Standard for the ARM 64-bit Architecture>,大体说明了ARM64的栈帧结构。
X86-64和ARM64用户栈的结构 (3) ---_start到__libc_start_main

2.2 adr,ldr和adrp 指令

看ARM64的汇编会常常遇到adr、ldr,adrp指令,下面将进行简短的介绍。

2.2.1 adr

主要用于造成pc相对地址,把相对地址load到寄存器中,使用方法为:

adr <xd>, <label>

当前指令到label的偏移 offset_to_label 加上PC的值,而后将结果赋值给xd。offset_to_label能够是个负数,实际在执行过程当中会将offse_to_label扩展成64为有符号数。可是ARM指令的长度是固定为32bit,offset_to_label最多只能为21位,也便可以寻PC +/-1MB的范围。
常常会被编译器转换成add或sub指令:

add  <xd>,[PC, #offset_to_label]  or
sub  <xd>,[PC, #-offset_to_label]

2.2.2 ldr

这个指令的本质做用是把地址中的数据加载到寄存器中,根据地址的表达形式不一样能够分为几种状况:

ldr <Xd>, <label>

将程序label处的数据load到Xd中,label是一个地址。指令记录的不是label的绝对地址,是当前指令到label的偏移,记做offset_to_labe,l和adr指令描述中的 offset_to_label 有所不一样。在汇编时,汇编器会计算当前指令到label的偏移量(以字节为单位),而后将偏移量右移两位获得 offset_to_label 。在执行执行指令时效果以下:

Xd <===  [PC + (offset_to_label << 2)]

另外几种以下:

ldr <Xt>,[<Xn|SP>],#<simm> post_index
ldr <Xt>,[<Xn|SP>,#<simm>]! pre_index
ldr <Xt>,[<Xn|SP>,#<pimm>] unsigned_offset

2.2.3 adrp

该指令在ARMv8中首次被设计出来,是ARM指令集的一个重大创新,能够减小指令条数以及访存的次数。有几篇博客介绍了该指令的做用,可是没有讲清楚,如《ARM指令浅析2(adrp、b)》《汇编7、ADRP指令》
指令的使用方式为:

adrp <Rd>, <label>

adrp就是address page 的简写,这里的page指的是大小为4KB的连续内存,和操做系统中的页不是一回事。该指令的做用是将label所在页且4KB对其的页基地址放入寄存器Xd中。Labe表示的地址确定在这个页基地址肯定的页内。要想完全搞懂这个指令的做用,还须要从指令汇编的过程和译码的过程进行分析。

adrp指令汇编

也就是将这个指令变成二进制机器码的过程,根据ARM文档,adrp指令的二进制格式为:
X86-64和ARM64用户栈的结构 (3) ---_start到__libc_start_main
32bit中的21bit immhi和immlo是由lable的地址(L)和当前指令所在的地址计算来的,第一步获取label和当前指令所在页的页基地址,二者相减获得差值;第二步将差值右移12位,再取低21位做为immhi:immlo。在进行指令汇编的时候,数据和指令在最终的二进制文件中的位置都肯定了,固然也能够肯定当前指令在所在的页基地址和lable所在的页基地址。
X86-64和ARM64用户栈的结构 (3) ---_start到__libc_start_main
如上图所示,在汇编时 immhi:immlo=(pageoffset_to_label>>12)&0x1FFFFF,Rd也是肯定的,就能够造成一条二进制机器码指令。

adrp 二进制指令译码

在cpu执行adrp 机器码指令时,能够根据PC和机器码指令中的immhi:immlo找到label所在页的基地址。在adrp指令发明后,对二进制文件的映射提出了一个要求,即二进制文件映射的虚拟地址必须4K对齐。在CPU执行adrp的机器码时,PC时已知的,根据PC就能够计算出label所在页的基地址:Rd=(PC & 0xFFFFFFFFFFFF0000) + (immhi:immlo << 12).
X86-64和ARM64用户栈的结构 (3) ---_start到__libc_start_main
到这里adrp指令的前先后后基本上也就介绍完了,还值得一提的是,获取label所在页的基地址自己没有什么用,因此通常在adrp指令的后面都会在跟一条add指令:add Rd, Rd,offset_inpage, label所在的地址就在寄存器Rd中了,就可使用load指令加载label处的数据了;或者直接使用ldr Rd, [Rd, #offset_inpage]加载label处的数据。

adrp的优点是什么? ARM是RISC指令集,每一个指令都是等长的32bit,这32bit能容下的东西颇有限,一个寻址指令除去自己的操做码,留给地址的bit位就没几个了,而有了adrp指令,相对寻址能力大大提高,能够寻址距离PC 4GB远的数据,既能够寻址PC前的4GB范围,也能够寻址PC后的4GB范围,由于immhi:immlo是21bit,offset_inpage是12bit,21+12=33。

2.3 _start 函数

在glibc的 sysdeps/aarch64/start.S中有_start函数,通过简单的处理以下所示:

_start:
    /* Create an initial frame with 0 LR and FP */
1:  mov  x29, #0
2:  mov  x30, #0
    /* Setup rtld_fini in argument register */
3:  mov  x5, x0
    /* Load argc and a pointer to argv */
4:  ldr  x1, [sp, #0]
5:  add  x2, sp, #8
    /* Setup stack limit in argument register */
6:  mov  x6, sp
7:  adrp x0, :got:main
8:  ldr  x0, [x0, #:got_lo12:main]
9:  adrp x3, :got:__libc_csu_init
10: ldr  x3, [x3, #:got_lo12:__libc_csu_init]
11: adrp x4, :got:__libc_csu_fini
12: ldr  x4, [x4, #:got_lo12:__libc_csu_fini]
    /* __libc_start_main (main, argc, argv, init, fini, rtld_fini,
                  stack_end) */
    /* Let the libc call main and exit with its return code.  */
13: bl  __libc_start_main
    /* should never get here....*/
14: bl  abort

上面的汇编,1~2行表示状况LR(Link Register) 和FP(Frame Pointer); 第4行是将argc传递给x1;第5行是将argv传递给x2,这里的argc和argv就是咱们平时写的C程序int main(int argc, char *argv[])函数的两个参数;其他几行相似,都是使用寄存器传递参数。ARM64的_start函数和X86-64的_start函数目的是同样的,都是调用__libc_start_mian函数,该函数的声明为:

__libc_start_main (int (*main) (int, char **, char **),
                   int argc,
                   char *argv,
                   void (*init) (void),
                   void (*fini) (void),
                   void (*rtld_fini) (void),
                   void *stack_end);

其中寄存器传递的参数为:

x0   main
x1   argc
x2   argv
x3   init
x4   fini
x5   rtld_fini
x6   stack_end

_start函数的做用以下图所示,下图的上半部分是Linux Kernel完成的和平台无关的设置,创建起了用户栈最初的部分,SP指向栈顶,栈中存放传递给__libc_start_main函数的参数argc和argv,Linux Kernel在这一点完成将用户的参数传递给用户程序的角色,同时也将栈的控制权转移给libc,而libc的__libc_start_main函数在将栈的控制权完成转移给用户的main函数以前,还会作一些额外的工做,发挥一些额外的做用
X86-64和ARM64用户栈的结构 (3) ---_start到__libc_start_main

相关文章
相关标签/搜索