由于不是科班出生,又是自学开发,对很多方面的知识都是只知其然而不知其所以然。加上最近公司事情不多,刚好乘此机会把长毛了小本本又翻了出来,希望能每天学习一点点,每天进步一点点。
之前没有接触过底层方面,所以这篇文章,会通过菜鸟视角,从基础概念到实战演练,一步步的揭开函数调用背后,寄存器,堆栈都干了些什么。
1. 栈区(stack)
高地址向低地址生长的一块连续的内存区域,所以栈顶地址和栈的最大容量都是系统预先规定好的;
编译器自动管理;
方式类似数据结构中的栈,后入先出(LIFO);
每个进程在用户态对应一个调用栈结构;
存放函数参数和返回值,函数局部变量(不包括 static 声明的变量,它们存放在静态变量区);
高效快速,但大小限制,数据不灵活(支持数据类型有限,一般是整型,指针,浮点型等系统直接支持的数据类型);
2. 栈帧(stack frame)
函数调用经常是嵌套的,在同一时刻,堆栈中会有多个函数的信息,每个未完成运行的函数占用一个独立连续区域(包含这个函数涉及的参数,局部变量,返回地址等相关信息),称为栈帧。
当调用函数时,就要压入一个新的栈帧,发起调用函数的栈帧成为调用者栈帧,被调用函数的栈帧则称为当前栈帧(rsp
和 rbp
之间的内存空间);被调用的函数运行结束后回收栈帧,回到调用者栈帧。这一过程都是自动的,由系统分配与销毁,无需手动调度。
3. 寄存器 (register)
x86-64中,所有寄存器都是64位,相对32位的x86来说,标识符发生了变化,比如:从原来的
%ebp
变成了%rbp
。为了向后兼容性,%ebp
依然可以使用,不过指向了%rbp
的低32位。
rip
指令地址寄存器,用来存储 CPU 即将要执行的指令地址。每次 CPU 执行完相应的汇编指令之后,rip
寄存器的值就会自行累加;rip
无法直接赋值,call, ret, jmp
等指令可以修改 rip
。
rbp
栈基地址寄存器,保存当前帧的栈底地址。
rsp
栈指针寄存器,保存当前栈顶。
栈帧中,最重要的是帧指针 rbp
和栈指针 rsp
,有了这两个指针,我们就可以刻画一个完整的栈帧。
3.1 寄存器保存惯例
调用者栈帧需要寄存器暂存数据,被调用者栈帧也需要寄存器暂存数据。
如果调用者使用了 rbx
,那被调用者就需要在使用之前把 rbx
保存起来,然后在返回调用者栈帧之前,恢复 rbx
。遵循该使用规则的寄存器就是被调用者保存寄存器,对于调用者来说, rbx
就是非易失的。
调用者使用 r10
存储局部变量,为了能在子函数调用后还能使用 r10
,调用者把 r10
先保存起来,然后在子函数返回之后,再恢复 r10
。遵循该使用规则的寄存器就是调用者保存寄存器,对于调用者来说, r10
就是易失的。
4. 函数调用栈
4.1 参数入栈
参数从右向左依次入栈(支持可变参数)。
x86-64 中,有 6 个寄存器来存储参数,多于 6 个参数,依然还是通过入栈实现。
4.2 返回地址入栈
实际代码中我们是看不到 push rip
这句的;
它是包含在 call
指令之中的 call function = push rip + jmp function
4.3 代码区跳转
它是包含在 call
指令之中的 call function = push rip + jmp function
4.4 栈帧调整
- 将调用帧的
push %rbp
入栈。 - 切换栈帧到当前栈帧
movq %rsp, %rbp
。 - 抬高栈顶,分配临时数据区
subq &xx, %rsp
。
5 实例测试
Xcode 新建工程,main.c
文件:
#include <stdio.h> //line 9
char* get_memory(char *a, char *b) {
char p[]="hello world";
return p;
}
int main(int argc, const char * argv[]) {
// insert code here...
char* str = NULL;
char* a = "good";
int b = 3;
float d = 12345.67890;
str = get_memory("h", "w");
printf("%s",str);
return 0;
}
5.1 实例分析
选中 main.c
文件,x86-64环境 Product -> Perform Action -> Assemble 'main.c'
。
生成的代码中会有很多 .
开头的,例如 .loc
,.section
等等,这些都是汇编器需要的,我们可以直接忽略,这篇文章对这些指令做了一些说明,清除掉它们和相关注释后我们重点关注 main
函数:
.section __TEXT,__text,regular,pure_instructions //.section __TEXT 只读和可执行的代码段
.macosx_version_min 10, 12
.file 1 ".../Project/test" ".../Project/test/test/main.c"
.globl _get_memory //`_get_memory` 是一个外部符号(Symbol),对于二进制文件外部可见。
.p2align 4, 0x90 //指出了后面代码的对齐方式。在我们的代码中,后面的代码会按照 16(2^4) 字节对齐,如果需要的话,用 0x90 补齐。
_get_memory: ## @get_memory
Lfunc_begin0:
.cfi_startproc
## BB#0:
pushq %rbp
Ltmp0:
.cfi_def_cfa_offset 16
Ltmp1:
.cfi_offset %rbp, -16
movq %rsp, %rbp
Ltmp2:
.cfi_def_cfa_register %rbp
subq $48, %rsp
leaq -20(%rbp), %rax
movq ___stack_chk_guard@GOTPCREL(%rip), %rcx
movq (%rcx), %rcx
movq %rcx, -8(%rbp)
movq %rdi, -32(%rbp)
movq %rsi, -40(%rbp)
Ltmp3:
##DEBUG_VALUE: get_memory:p <- %RAX
movq L_get_memory.p(%rip), %rcx
movq %rcx, -20(%rbp)
movl L_get_memory.p+8(%rip), %edx
movl %edx, -12(%rbp)
movq ___stack_chk_guard@GOTPCREL(%rip), %rcx
movq (%rcx), %rcx
movq -8(%rbp), %rsi
cmpq %rsi, %rcx
movq %rax, -48(%rbp) ## 8-byte Spill
Ltmp4:
##DEBUG_VALUE: get_memory:p <- [%RBP+-48]
jne LBB0_2 //如果 rsi 和 rcx 不相等,那么就跳转到 LBB0_2
## BB#1:
##DEBUG_VALUE: get_memory:p <- [%RBP+-48]
movq -48(%rbp), %rax ## 8-byte Reload
addq $48, %rsp
popq %rbp
retq
LBB0_2:
##DEBUG_VALUE: get_memory:p <- [%RBP+-48]
callq ___stack_chk_fail
Ltmp5:
Lfunc_end0:
.cfi_endproc
.section __TEXT,__literal4,4byte_literals
.p2align 2
LCPI1_0:
.long 1178658487 ## float 12345.6787
.section __TEXT,__text,regular,pure_instructions
.globl _main
.p2align 4, 0x90
_main: ## @main
Lfunc_begin1:
.cfi_startproc //函数开始标识,用于初始化某些内部数据结构
## BB#0:
pushq %rbp //保存调用者的栈帧基址--控制链
Ltmp6:
.cfi_def_cfa_offset 16 //此处距离 CFA 16 字节(用的 rsp 计算)
Ltmp7:
.cfi_offset %rbp, -16 //rbp 的值,保存在距离 CFA 16 字节处
movq %rsp, %rbp //设置新的栈帧基址
Ltmp8:
.cfi_def_cfa_register %rbp //修改计算 CFA 所用的寄存器,设成 rbp
subq $48, %rsp //分配临时数据区
leaq L_.str.1(%rip), %rax //将 L_.str.1 的指针加载到 rax 寄存器 "h"
leaq L_.str.2(%rip), %rcx //将 L_.str.2 的指针加载到 rcx 寄存器 "w"
movss LCPI1_0(%rip), %xmm0 ## xmm0 = mem[0],zero,zero,zero //将 LCPI1_0 的单精度值加载到 xmm0 寄存器的低双字
leaq L_.str(%rip), %rdx //将 L_.str 的指针加载到 rdx 寄存器 "good"
movl $0, -4(%rbp)
movl %edi, -8(%rbp) //将第一个参数(int argc)的值存在 rbp 低位偏移8字节
movq %rsi, -16(%rbp) //将第二个参数(char *argv[])的值存在 rbp 低位偏移16字节
Ltmp9:
movq $0, -24(%rbp) //第一个变量(char* str)值为0,存在 rbp 低位偏移24字节
movq %rdx, -32(%rbp) //第二个变量(char* a)值为 rdx 的值(即前面 L_.str 的指针),存在 rbp 低位偏移32字节
movl $3, -36(%rbp) //第三个变量(int b)值为3,存在 rbp 低位偏移36字节
movss %xmm0, -40(%rbp) //第四个变量(float d)值为 xmm0 的值,存在 rbp 低位偏移40字节
movq %rax, %rdi //将 get_memory 函数第一个参数值(之前存在 rax 寄存器的指针)设置到寄存器 edi
movq %rcx, %rsi //将 get_memory 函数第二个参数值(之前存在 rcx 寄存器的指针)设置到寄存器 rsi
callq _get_memory //调用 get_memory 函数
leaq L_.str.3(%rip), %rdi //将 printf 函数第一个参数(L_.str.3 的指针)加载到 rdi 寄存器中 "%s"
movq %rax, -24(%rbp) //将 get_memory 返回值设置给前面初始化过的第一个变量
movq -24(%rbp), %rsi //将 printf 函数第第二个参数(char* str)设置到寄存器 rsi
movb $0, %al //printf 是可变参数函数,ABI 调用约定指定,将会把使用来存储参数的寄存器数量存储在寄存器 al 中,这里是 0
callq _printf //调用 printf 函数
xorl %r8d, %r8d //清 0 r8d 寄存器
movl %eax, -44(%rbp) ## 4-byte Spill //printf 的返回值存在 rbp 低位偏移44字节
movl %r8d, %eax //清 0 eax 低32位
addq $48, %rsp //堆栈指针 rsp 上移 48 字节
popq %rbp //之前存储至 rbp 中的值弹出
retq
Ltmp10:
Lfunc_end1:
.cfi_endproc //与开始时的 .cfi_startproc 对应,结束
.section __TEXT,__cstring,cstring_literals
L_get_memory.p: ## @get_memory.p
.asciz "hello world"
L_.str: ## @.str
.asciz "good"
L_.str.1: ## @.str.1
.asciz "h"
L_.str.2: ## @.str.2
.asciz "w"
L_.str.3: ## @.str.3
.asciz "%s"
5.2 栈图
5.3 说明
CFI 是调用框架指令(Call Frame Information)缩写,提供的调用框架信息, 为实现堆栈回绕(stack unwiding)或异常处理(exception handling)提供了方便。
.cfi_startproc
用于函数开始,.cfi_endproc
用于函数结束,两者配套使用。-
.cfi_def_cfa_offset 16
指令表示此处(rsp)距离 CFA 16 字节。CFA(Canonical Frame Address)是标准框架地址,指调用者栈帧中调用点处的栈指针值。
.cfi_def_cfa_offset offset
modifies a rule for computing CFA(Canonical Frame Address). Register remains the same, but offset is new. Note that it is the absolute offset that will be added to a defined register to compute CFA address. -
.cfi_offset %rbp, -16
指令表示rbp
的值保存在距离CFA
16 字节。rbp
是被调用者保存寄存器,按照惯例,被调者在使用之前要保存起来。 -
.cfi_offset register %rbp
指令表示,从这里开始,使用rbp
作为计算CFA
的基址寄存器:- 在这之前的
cfi_def_cfa_offset 16
用的是rsp
; - 前一条指令
movq %rsp, %rbp
已经将rsp
设置为新的rbp
。
- 在这之前的
-
movl $3, -36(%rbp)
:-
%
用于直接寻址寄存器,$
表示立即数。movl $3, %rbp
表示把立即数 3 存到rbp
中。 -
()
用于内存间接寻址,movl $3, (%rbp)
表示将立即数 3 保存到rbp
所指向的内存地址中。 -
-36(%rbp)
表示先找到rbp
所指向地址,再 -36 后所得到的地址。
-
-
浮点数存储方式
LCPI1_0: .long 1178658487 ## float 12345.6787 ... movss LCPI1_0(%rip), %xmm0 ...
单精度浮点数的存储方式:
| 1位符号数 | 8位指数 | 23位尾数 |
-
12345.67890
被转成了了十进制数1178658487
; - 我们把
1178658487
转换成二进制0 10001100 10000001110011010110111
; - 第一位符号位
0
表示正数; - 第二位到第九位转为十进制
140
; - 实际指数 =
140 - 127 = 13
;由于指数需要表示正负两种数据,IEEE标准规定单精度指数以127为分割线,实际存储的数据是指数加127所得结果,127为高位为零,后7位为1所得,其他双精度也以此方式计算。
6. 尾数加上1.
为1.10000001110011010110111
扩大2 ^ 23 次方为110000001110011010110111
十进制12641975
;
7. 12641975 / 2 ^ (23 - 13(步骤5算出的指数)) = 12641975 / 1024 = 12345.6787109375;
-
5.4 刨根
接下来,我们直接深入内存,来验证一下我们上面是否在一本正经的胡说八道。
Xcode 中勾选 Debug -> Debug Workflow -> Always Show Disassembly
后,在 main
方法打断点,就能进入汇编调试界面。
-
All
的位置,默认选项是auto
,看不到寄存器状态。 - 也可以用
register read
指令查看寄存器状态。
直接在 callq 0x100000c90 ; get_memory at main.c:11
位置,检查进入 get_memory
方法之前的寄存器和栈,是否和我们上面的栈图吻合。
息栏可以看到各寄存器中保存的信息,可以直接看到 rbp
指向的地址 0x00007fff5fbff6e0
。
subq $0x30, %rsp
这句将栈抬高了 48 字节,所以我们查看内存的时候要从 0x7FFF5FBFF6B0
开始。
上方菜单栏 Debug -> Debug Workflow -> View Memory
打开内存调试界面。
为了方便查看,我将图中的内存,按照我们之前的栈图进行了调整:
0x7fff5fbff6e0 rbp
0x7fff5fbff6dc 00 00 00 00 0 -4(%rbp)
0x7fff5fbff6d8 01 00 00 00 1 -8(%rbp)
0x7fff5fbff6d0 00 F7 BF 5F FF 7F 00 00 00007FFF5FBFF700 -16(%rbp)
0x7fff5fbff6c8 00 00 00 00 00 00 00 00 0 -24(%rbp)
0x7fff5fbff6c0 5C 0F 00 00 01 00 00 00 0000000100000F5C -32(%rbp)
0x7fff5fbff6bc 03 00 00 00 3 -36(%rbp)
0x7fff5fbff6b8 B7 E6 40 46 12345.67890 -40(%rbp)
0x7fff5fbff6b4 00 00 00 00 0 -44(%rbp)
0x7fff5fbff6b0 00 00 00 00 0 -48(%rbp)
根据栈图 -32(%rbp)
位置应该存放的是 "good"
的内存地址,同样的步骤直接查看地址 0x0000000100000F5C
:
Xcode 已经帮我们标出来16进制对应的ASCII可显示内容了,当然也可以到这里对照验证一下。