从 简单汇编基础 到 Swift 不简单的 a + 1
作为iOS开发,程序崩溃犹如家常便饭,秉着没有崩溃也要制造崩溃的原则
每天都吃的很饱
但学艺不精的经常有这样的困扰,每次崩溃都定位到一堆。类似
movq $0x0, 0xc7a(%rip) 的天书里面
初识汇编
虽然不知道movq是什么意思,但知道move
move 的意思,没错是 飘逸
至于q,不管 q不q 的,哎e呢?
汇编语言
汇编语言:(assembly language) 是一种用于 电子计算机、微处理器、微控制器,或其他可编程器件的低级语言 – 维基百科
简单来说,平时写的代码都是高级语言,计算机不理解高级语言,就像吃饭不吃塑料包装一样,吃的是里面的东西
汇编语言是二进制指令的 文本形式,计算机会把代码转换为汇编语言,汇编语言通过机器指令 还原成 二进制代码,也就是所谓的 0,1,计算机就可以执行了。
每一个 CPU的机器指令不同,所以对应的汇编语言也不同。
寄存器
为什么需要了解寄存器?
因为汇编语言 的数据存储 与寄存器和内存 息息相关
一般来说,数据是放在内存中的,CPU 计算的时候就去内存里拿数据,但是
CPU 的运算速度 > 内存的运算速度
就仿佛
吃饭的速度 > 食堂大妈打菜的速度
受不了,大妈受得了吗?
所以CPU 自带了一级,二级缓存,相当于大妈让她儿子给送饭
问题是这个中间层还是慢且不稳定
CPU 缓存的数据地址是 不固定的,意味着点了份 西红柿盖浇饭,让店员给送到座位上,店员找了半个小时,发现坐在别人店里。
所以CPU 有了寄存器,来存储频繁使用的数据。CPU 通过寄存器 跟 内存 间接交换数据
寄存器都有自己的名称(如 rax ,rdx等)
说坐在C区21号,店员还不是分分钟把饭塞到嘴里,质问:喂,还要饭吗?
所以CPU 会去 指定名称的 寄存器拿数据,这样速度就不快了嘛
天下武功,唯快不破。
所以为什么需要寄存器,因为读写速度够快
内存
说到底,寄存器依旧是一个暂存区,只是一个中间站,真正存储数据,操作数据的还是内存。
以下是内存分布图:
简单介绍一下堆栈
• 堆 heap
o 分配方式:alloc,速度相对栈比较慢,容易产生内存碎片
o 管理方式: 程序员,ARC下面,堆区的分配和释放基本也是系统操作
o 地址分布:从低到高,非连续
o 大小:取决于计算机系统的有效的虚拟空间
o 作用:动态分配内存,存储变量,延长生命周期
• 栈 stack
o 一端进行插入和删除操作的特殊线性表
o 分配方式: 系统,速度比较快
o 管理方式: 系统,不受程序员控制
o 地址分布:从高到低,连续
o 大小:栈顶的地址和容量是系统决定
o 生命周期:出了作用域就会释放
o 入栈出栈:先进后出,类似羽毛球筒,先放入的羽毛球,总是最后才能拿到
在Linux 下,iterm2 敲下ulimit -a,可以看到栈分配的默认大小为 8192 ,也就是 8M
–t: cpu time (seconds) unlimited
-f: file size (blocks) unlimited
–d: data seg size (kbytes) unlimited
–s: stack size (kbytes) 8192
复制代码
汇编语言
因为是iOS开发,所以就只稍微了解了 AT&T 汇编 的皮毛
虽然看起来会枯燥一点,但是理解这些比较常用的寄存器,对汇编代码的理解就会有质的飞跃
之前是门外汉
现在好歹算个半个汇编人
iOS 模拟器、MAC OS、Linux : AT&T汇编 ;
iOS 真机: ARM 汇编
复制代码
x86-64 中,AT&T 中常用的 寄存器有 16种:
• %rax、%rbx、%rcx、%rdx、%rsi、%rdi、%rbp、%rsp
• %r8、%r9、%r10、%r11、%r12、%r13、%r14、%r15
常用寄存器
AT&T 常用寄存器介绍:
%rax:常作为函数返回值。 一般来说,为了向后兼容,64位的寄存器会兼容32的寄存器,32和64可以一起使用
64位: 8个字节 ,以 r 开头; 32位: 4个字节,以e 开头,看图
在64位的寄存器 rax中,为了兼容分配了较低的32位,也就是4个字节 给了 eax。基本上,汇编出现的eax 就是 代表rax,eax是 rax 的一部分,其他 部分寄存器同理
%rdi、%rsi、%rdx、%rcx、%r8、%r9: 常作为函数参数
r8,r9 这种32位的表示法,通常在后面加d,如r8d,r9d
%rip: 指令指针,存储CPU 即将执行的指令地址
• 解释一下rip
即将执行: 下一条执行
指令地址: 开头的那一串 0x100…
截取2句汇编:
7 – 0x100000a64 <+20>: movq $0x1, 0x719(%rip)
8 – 0x100000a6f <+31>: movl %edi, -0x34(%rbp)
复制代码
第7行中的 0x719(%rip) 中的 rip 就是指令指针,即将执行的 地址 就是 第8行 开头的那个地址0x100000a6f
所以这里rip 的地址就是 0x100000a6f,有了rip 的地址
一般来说
0x719(%rip) 就是 0x719 + %rip地址
-0x719(rip) 就是 %rip – 0x719
复制代码
栈相关
%rbp: 栈基址指针也称为帧指向,指向栈底
%rsp: 栈指针,指向栈顶
常用指令
一些比较常见的能理解的指令
那么这个q 是干什么的呢 ?callq ,leaq ,movq 都有q?
• 这里的q 是 代表字节大小
o b–byte 字节,操作位宽 1个字节
o w-word ,2个字节
o l-long ,4个字节
o q-quadword,8个字节
q意味着,寄存器操作的数据类型 需要占用的 操作位宽,当然这根据 数据类型决定
复制代码
所以上面那句代码
movq $0x1, 0x719(%rip)
意思是,立即数 1 寻址 (0x719 + %rip),并赋值。将 1 赋值给 (0x719 + 0x100000a6f) 这个地址,操作位宽是8 个字节
读取寄存器
介绍几个 lldb 的常用指令,可以方便查阅 寄存器的值
• register read/格式: 读取寄存器的值
register read/x rax // 读取寄存器 rax 里面的值
x:16进制
f:浮点
d:10进制
复制代码
• register write 修改寄存器的值
(lldb) register read/x rax
rax = 0x0000000000000003
(lldb) register write rax 4 // 修改为4
(lldb) register read/x rax
rax = 0x0000000000000004
复制代码
• x/数量-格式-字节大小: 读取内存中的值
x/4xg 0x1000002
// 将 0x1000002 地址的值,以8个字节的格式,分成4份,16进制 展示
// 这里是展示 和 上面的操作不太一样,g 表示8个字节
b – byte 1字节
h – half word 2字节
w – word 4字节
g – giant word 8字节
如果数据的值不够分成4份,剩下的字节以0 补齐
复制代码
栈帧
帧,在电影中指每一张画面,一种平均单位
栈帧:站着的帧,画面立体了起来,不单单是一个角度,里面包含了很多信息
包含了
每一次* 函数调用涉及的相关信息
局部变量、函数返回地址、函数参数等
复制代码
都知道,函数的调用是会在栈上分配内存的,分配多少取决于函数的参数和局部变量
那么一个函数的占用的内存大小,函数的返回地址,就需要保存起来,这就用到了栈帧
• 为什么需要保存函数的信息?
因为函数运行完毕 ,在栈上需要释放内存,以及继续执行上一层代码,需要上一层函数的返回地址,在本次函数执行完毕后,恢复父函数的栈帧结构
想象这样一个场景
类比一下接力赛中,4位选手
栈顶 1 -> 2 -> 3 -> 4 栈底,每一位选手都要在拿到接力棒后,才会开跑
那么 1号选手,就需要保存2号选手的信息,不需要知道 3号 和 4号
下一个接棒者 长什么样?身上的号码牌?站在哪里?
1 号选手结束之后, 赛场队伍就只剩 2 -> 3 -> 4,此时焦点就集中在2号选手
选手跑步 -> 函数调用
选手信息 -> 栈帧保存的信息
视线焦点 -> 栈指针,指向当前选手
只有清楚了下一位的接棒人(在栈中对应上一层函数),才能在本次结束之后找到正确的位置,继续执行流程
复制代码
至于信息的保存者? 取决于寄存器的标识 Caller Save 和 Callee Save
当子函数调用的时候,也会用到父函数的寄存器,可能会存在覆盖寄存器的值。
父函数调用子函数之前,将寄存器的值保存一份,这样子函数就可以随意覆盖
父函数不保存,交由子函数 保存和恢复 寄存器的值
复制代码
例子
简单的建立一个 命令行 工程,打开汇编 Always Show Disassembly
用 Swift 写出以下代码
func test() -> Int {
var a = 3
a = a + 1
return a
}
-> test() // 断点指向test,run
复制代码
程序运行起来,程序断点在 test 函数调用的地方
zzzmain: 0x100000bc0 <+0>: pushq %rbp 0x100000bc1 <+1>: movq %rsp, %rbp 0x100000bc4 <+4>: subq $0x20, %rsp 0x100000bc8 <+8>: movl %edi, -0x4(%rbp) 0x100000bcb <+11>: movq %rsi, -0x10(%rbp) -> 0x100000bcf <+15>: callq 0x100000bf0 ; zzz.test() -> Swift.Int at main.swift:189 0x100000bd4 <+20>: xorl %edi, %edi 0x100000bd6 <+22>: movq %rax, -0x18(%rbp) 0x100000bda <+26>: movl %edi, %eax 0x100000bdc <+28>: addq $0x20, %rsp 0x100000be0 <+32>: popq %rbp 0x100000be1 <+33>: retq 复制代码 控制台 用 si 进入 test 函数内部 可以看到 test 内部的汇编代码,参考下面的图,说一说理解 zzz
test():
-> 0x100000bf0 <+0>: pushq %rbp
0x100000bf1 <+1>: movq %rsp, %rbp
0x100000bf4 <+4>: movq $0x0, -0x8(%rbp)
0x100000bfc <+12>: movq $0x3, -0x8(%rbp)
0x100000c04 <+20>: movq $0x4, -0x8(%rbp)
0x100000c0c <+28>: movl $0x4, %eax
0x100000c11 <+33>: popq %rbp
0x100000c12 <+34>: retq
复制代码
• 借图,侵删
子函数调用时,调用者与被调用者的栈帧结构
分析
test 函数 一进来,就执行了下面两句代码
-> 0x100000bf0 <+0>: pushq %rbp
0x100000bf1 <+1>: movq %rsp, %rbp
复制代码
一开始,test 函数 就进行了 压栈
pushq %rbp
压栈的是父函数 main函数的 栈帧指针 %rbp
% rbp指向的返回地址, 是main 函数 调用完 test ,应该回到哪里的地址,也就是当前函数test 调用开始时 栈的位置
而此时 test 函数的 %rbp ,相当于是新的%rbp
然后通过
movq %rsp, %rbp
将%rsp 也 指向 %rbp,test 栈帧 的初始位置
因为%rsp 总是指向新的元素,所以在被 一些局部变量等 填充之后,来到了栈顶
函数的调用: 栈帧被创建 -> 填充 -> 销毁
接着
0x100000bf4 <+4>: movq $0x0, -0x8(%rbp)
0x100000bfc <+12>: movq $0x3, -0x8(%rbp)
0x100000c04 <+20>: movq $0x4, -0x8(%rbp)
复制代码
将 立即数 0 ,赋值给 %rbp – 0x8的 8个字节 的内存空间 用于初始化
后面又将 参数3,覆盖,以及计算+1 的值 继续覆盖,这里应该是省略了 +1 的操作
接着
movl $0x4, %eax
前面说过,%rax 通常作为返回值,%eax 是 %rax 的32位表示,将 立即数4赋值给 %eax作为返回值
这里用到了movl 和 %eax,是因为 int类型 占用4个字节,只需要 4个字节即可,所以用到了 %rax 的 较低的 32位 `
到这里就得到了 test函数的 返回值 4
再来
0x100000c11 <+33>: popq %rbp
0x100000c12 <+34>: retq
复制代码
前有 push ,后就有pop,将test 中的寄存器 %rbp 从栈中弹出,恢复调用前的 rbp,而
retq 等价于 popq %rip,前面说过rip 代表着 下一条指令
将%rip 指令指针,从新指回 test 函数调用后的 下一条 指令,这样程序就可以继续运行了
此时的 内存分布
test 函数的内存空间,随着作用域的结束,就被释放了
到底为止,就简单的理解了 test 函数 a + 1的 汇编过程
x86-64 下函数调用及栈帧原理
缘起
在 C/C++ 程序中,函数调用是十分常见的操作。这一操作的底层原理是怎样的?编译器帮做了哪些操作?CPU 中各寄存器及内存堆栈在函数调用时是如何被使用的?栈帧的创建和恢复是如何完成的?针对上述问题,本本文进行了探索和研究。
通用寄存器使用惯例
函数调用时,在硬件层面需要关注的通常是cpu 的通用寄存器。在所有 cpu 体系架构中,每个寄存器通常都是有建议的使用方法的,而编译器也通常依照CPU架构的建议来使用这些寄存器,因而可以认为这些建议是强制性的。
对于 x86-64 架构,共有16个64位通用寄存器,各寄存器及用途如下图所示:
从上图中,可以得到如下结论:
• 每个寄存器的用途并不是单一的。
• %rax 通常用于存储函数调用的返回结果,同时也用于乘法和除法指令中。在imul 指令中,两个64位的乘法最多会产生128位的结果,需要 %rax 与 %rdx 共同存储乘法结果,在div 指令中被除数是128 位的,同样需要%rax 与 %rdx 共同存储被除数。
• %rsp 是堆栈指针寄存器,通常会指向栈顶位置,堆栈的 pop 和push 操作就是通过改变 %rsp 的值即移动堆栈指针的位置来实现的。
• %rbp 是栈帧指针,用于标识当前栈帧的起始位置
• %rdi, %rsi, %rdx, %rcx,%r8, %r9 六个寄存器用于存储函数调用时的6个参数(如果有6个或6个以上参数的话)。
• 被标识为 “miscellaneous registers” 的寄存器,属于通用性更为广泛的寄存器,编译器或汇编程序可以根据需要存储任何数据。
这里还要区分一下 “Caller Save” 和 ”Callee Save” 寄存器,即寄存器的值是由”调用者保存“ 还是由 ”被调用者保存“。当产生函数调用时,子函数内通常也会使用到通用寄存器,那么这些寄存器中之前保存的调用者(父函数)的值就会被覆盖。为了避免数据覆盖而导致从子函数返回时寄存器中的数据不可恢复,CPU 体系结构中就规定了通用寄存器的保存方式。
如果一个寄存器被标识为”Caller Save”, 那么在进行子函数调用前,就需要由调用者提前保存好这些寄存器的值,保存方法通常是把寄存器的值压入堆栈中,调用者保存完成后,在被调用者(子函数)中就可以随意覆盖这些寄存器的值了。如果一个寄存被标识为“Callee Save”,那么在函数调用时,调用者就不必保存这些寄存器的值而直接进行子函数调用,进入子函数后,子函数在覆盖这些寄存器之前,需要先保存这些寄存器的值,即这些寄存器的值是由被调用者来保存和恢复的。
函数的调用
子函数调用时,调用者与被调用者的栈帧结构如下图所示:
在子函数调用时,执行的操作有:父函数将调用参数从后向前压栈 -> 将返回地址压栈保存 -> 跳转到子函数起始地址执行 -> 子函数将父函数栈帧起始地址(%rpb) 压栈 -> 将 %rbp 的值设置为当前 %rsp 的值,即将 %rbp 指向子函数栈帧的起始地址。
上述过程中,保存返回地址和跳转到子函数处执行由 call 一条指令完成,在call 指令执行完成时,已经进入了子程序中,因而将上一栈帧%rbp 压栈的操作,需要由子程序来完成。函数调用时在汇编层面的指令序列如下:
… # 参数压栈
call FUNC # 将返回地址压栈,并跳转到子函数 FUNC 处执行
… # 函数调用的返回位置
FUNC: # 子函数入口
pushq %rbp # 保存旧的帧指针,相当于创建新的栈帧
movq %rsp, %rbp # 让 %rbp 指向新栈帧的起始位置
subq $N, %rsp # 在新栈帧中预留一些空位,供子程序使用,用 (%rsp+K) 或 (%rbp-K) 的形式引用空位
保存返回地址和保存上一栈帧的%rbp 都是为了函数返回时,恢复父函数的栈帧结构。在使用高级语言进行函数调用时,由编译器自动完成上述整个流程。对于”Caller Save” 和 “Callee Save” 寄存器的保存和恢复,也都是由编译器自动完成的。
父函数中进行参数压栈时,顺序是从后向前进行的。但是,这一行为并不是固定的,是依赖于编译器的具体实现的,在gcc 中,使用的是从后向前的压栈方式,这种方式便于支持类似于 printf(“%d, %d”, i, j) 这样的使用变长参数的函数调用。
函数的返回
函数返回时,只需要得到函数的返回值(保存在 %rax 中),之后就需要将栈的结构恢复到函数调用之差的状态,并跳转到父函数的返回地址处继续执行。由于函数调用时已经保存了返回地址和父函数栈帧的起始地址,要恢复到子函数调用之前的父栈帧,只需要执行以下两条指令:
movq %rbp, %rsp # 使 %rsp 和 %rbp 指向同一位置,即子栈帧的起始处
popq %rbp # 将栈中保存的父栈帧的 %rbp 的值赋值给 %rbp,并且 %rsp 上移一个位置指向父栈帧的结尾处
为了便于栈帧恢复,x86-64 架构中提供了 leave 指令来实现上述两条命令的功能。执行 leave 后,前面图中函数调用的栈帧结构如下:
可以看出,调用 leave 后,%rsp 指向的正好是返回地址,x86-64 提供的 ret 指令,其作用就是从当前 %rsp 指向的位置(即栈顶)弹出数据,并跳转到此数据代表的地址处,在leave 执行后,%rsp 指向的正好是返回地址,因而 ret 的作用就是把 %rsp 上移一个位置,并跳转到返回地址执行。可以看出,leave 指令用于恢复父函数的栈帧,ret 用于跳转到返回地址处,leave 和ret 配合共同完成了子函数的返回。当执行完成 ret 后,%rsp 指向的是父栈帧的结尾处,父栈帧尾部存储的调用参数由编译器自动释放。
函数调用示例
为了更深入的了解函数调用原理,可以使用一个程序示例来观察函数的调用和返回。程序如下:
int add(int a, int b, int c, int d, int e, int f, int g, int h) { // 8 个参数相加
int sum = a + b + c + d + e + f + g + h;
return sum;
}
int main(void) {
int i = 10;
int j = 20;
int k = i + j;
int sum = add(11, 22,33, 44, 55, 66, 77, 88);
int m = k; // 为了观察 %rax Caller Save 寄存器的恢复
return 0;
}
在main 函数中,首先进行了一个 k=i+j 的加法,这是为了观察 Caller Save 效果。因为加法会用到 %rax,add 函数的返回值也会使用 %rax。由于 %rax 是 Caller Save 寄存器,在调用 add 子函数之前,程序应该先保存 %rax 的值。
add 函数使用了 8 个参数,这是为了观察当函数参数多于6个时程序的行为,前6个参数会保存到寄存器中,多于6个的参数会保存到堆栈中。但是,由于在子程序中可能会取参数的地址,保存在寄存器中的前6个参数是没有内存地址的,因而可以猜测,保存在寄存器中的前6个参数,在子程序中也会被压入到堆栈中,这样才能取到这6个参数的内存地址。上面程序生成的和子函数调用相关的汇编程序如下:
add:
.LFB2:
pushq %rbp
.LCFI0:
movq %rsp, %rbp
.LCFI1:
movl %edi, -20(%rbp)
movl %esi, -24(%rbp)
movl %edx, -28(%rbp)
movl %ecx, -32(%rbp)
movl %r8d, -36(%rbp)
movl %r9d, -40(%rbp)
movl -24(%rbp), %eax
addl -20(%rbp), %eax
addl -28(%rbp), %eax
addl -32(%rbp), %eax
addl -36(%rbp), %eax
addl -40(%rbp), %eax
addl 16(%rbp), %eax
addl 24(%rbp), %eax
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
leave
ret
main:
.LFB3:
pushq %rbp
.LCFI2:
movq %rsp, %rbp
.LCFI3:
subq $48, %rsp
.LCFI4:
movl $10, -20(%rbp)
movl $20, -16(%rbp)
movl -16(%rbp), %eax
addl -20(%rbp), %eax
movl %eax, -12(%rbp)
movl $88, 8(%rsp)
movl $77, (%rsp)
movl $66, %r9d
movl $55, %r8d
movl $44, %ecx
movl $33, %edx
movl $22, %esi
movl $11, %edi
call add
movl %eax, -8(%rbp)
movl -12(%rbp), %eax
movl %eax, -4(%rbp)
movl $0, %eax
leave
ret
在汇编程序中,如果使用的是64位通用寄存器的低32位,则寄存器以 ”e“ 开头,比如 %eax,%ebx 等,对于 %r8-%r15,其低32 位是在64位寄存后加 “d” 来表示,比如 %r8d, %r15d。如果操作数是32 位的,则指令以 ”l“ 结尾,例如 movl $11, %esi,指令和寄存器都是32位的格式。如果操作数是64 位的,则指令以 q 结尾,例如 “movq %rsp, %rbp”。由于示例程序中的操作数全部在32位的表示范围内,因而上面的加法和移动指令全部是用的32位指令和操作数,只有在创建栈帧时为了地址对齐才使用的是64位指令及操作数。
首先看 main 函数的前三条汇编语句:
.LFB3:
pushq %rbp
.LCFI2:
movq %rsp, %rbp
.LCFI3:
subq $48, %rsp
这三条语句保存了父函数的栈帧(注意main函数也有父函数),之后创建了main 函数的栈帧并且在栈帧中分配了48Byte 的空位,这三条语句执行完成后,main 函数的栈帧如下图所示:
之后,main 函数中就进行了 k=i+j 的加法和 add 参数的处理:
movl $10, -20(%rbp)
movl $20, -16(%rbp)
movl -16(%rbp), %eax
addl -20(%rbp), %eax
movl %eax, -12(%rbp) # 调用子函数前保存 %eax 的值到栈中,caller save
movl $88, 8(%rsp)
movl $77, (%rsp)
movl $66, %r9d
movl $55, %r8d
movl $44, %ecx
movl $33, %edx
movl $22, %esi
movl $11, %edi
call add
在进行 k=i+j 加法时,使用 main 栈空间的方式较为特别。并不是按照通常认为的每使用一个栈空间就会进行一次push 操作,而是使用之前预先分配的 48 个空位,并且用 -N(%rbp) 即从 %rbp 指向的位置向下计数的方式来使用空位的,本质上这和每次进行 push 操作是一样的,最后计算 i+j 得到的结果 k 保存在了 %eax 中。之后就需要准备调用 add 函数了。
add 函数的返回值会保存在 %eax 中,即 %eax 一定会被子函数 add 覆盖,而现在 %eax 中保存的是 k 的值。在 C 程序中可以看到,在调用完成 add 后,又使用了 k 的值,因而在调用 add 中覆盖%eax 之前,需要保存 %eax 值,在add 使用完%eax 后,需要恢复 %eax 值(即k 的值),由于 %eax 是 Caller Save的,应该由父函数main保存 %eax 的值,因而上面汇编中有一句 “movl %eax, -12(%rbp)” 就是在调用 add 函数之前来保存 %eax 的值的。
对于8个参数,可以看出,最后两个参数是从后向前压入了栈中,前6个参数全部保存到了对应的参数寄存器中,与本文开始描述的一致。
进入 add 之后的操作如下:
add:
.LFB2:
pushq %rbp # 保存父栈帧指针
.LCFI0:
movq %rsp, %rbp # 创建新栈帧
.LCFI1:
movl %edi, -20(%rbp) # 在寄存器中的参数压栈
movl %esi, -24(%rbp)
movl %edx, -28(%rbp)
movl %ecx, -32(%rbp)
movl %r8d, -36(%rbp)
movl %r9d, -40(%rbp)
movl -24(%rbp), %eax
addl -20(%rbp), %eax
addl -28(%rbp), %eax
addl -32(%rbp), %eax
addl -36(%rbp), %eax
addl -40(%rbp), %eax
addl 16(%rbp), %eax
addl 24(%rbp), %eax
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
leave
ret
add 中最前面两条指令实现了新栈帧的创建。之后把在寄存器中的函数调用参数压入了栈中。在本文前面提到过,由于子程序中可能会用到参数的内存地址,这些参数放在寄存器中是无法取地址的,这里把参数压栈,正好印证了之前的猜想。
在参数压栈时,看到并未使用 push 之类的指令,也没有调整 %esp 指针的值,而是使用了 -N(%rbp) 这样的指令来使用新的栈空间。这种使用”基地址+偏移量“ 使用栈的方式和直接使用 %esp 指向栈顶的方式其实是一样的。
这里有两个和编译器具体实现相关的问题:一是上面程序中,-8(%rbp) 和 -12(%rbp) 地址并未被使用到,这两个地址之前的地址 -4(%rbp) 和之后的 -16(%rsp) 都被使用到了,这可能是由于编译器具体的实现方式来决定的。另外一个就是如下两条指令:
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
先是把 %eax 的值赋值给的 -4(%rbp),之后又逆向赋值了一次,可能是编译器为了通用性才如此操作的。以上两个问题需要后续进一步研究。
当add函数返回后,返回结果会存储在%eax 中,%rbp 和 %rsp 会调整为指向 main 的栈帧,之后会执行main 函数中的如下指令:
movl %eax, -8(%rbp) # 保存 add 函数返回值到栈中,对应 C 语句 int sum = add(…)
movl -12(%rbp), %eax # 恢复 call save 寄存器 %eax 的值,与调用add前保存 %eax 相对应
movl %eax, -4(%rbp) # 对应 C 语句 m = k,%eax 中的值就是 k。
movl $0, %eax # main 函数返回值
leave # main 函数返回
ret
可以看出,当 add 函数返回时,把返回值保存到了 %eax 中,使用完返回值后,会恢复 caller save 寄存器 %eax的值,这时main 栈帧与调用 add 之前完全一样。
在调用 add 之前,main 中执行了一条 subq 48, %rsp 这样的指令,原因就在于调用 add 之后,main 中并未调用其他函数,而是执行了两条赋值语句后就直接从main返回了。 main 结尾处的 leave、ret 两条指令会直接覆盖 %rsp 的值从而回到 main 的父栈帧中。如果先调整 main 栈帧的 %rsp 值,之后 leave 再覆盖 %rsp 的值,相当于调整是多余的。因而省略main 中 add返回之后的 %rsp 的调整,而使用 leave 直接覆盖%rsp更为合理。
结语
本文从汇编层面介绍了X86-64 架构下函数调用时栈帧的切换原理,了解这些底层细节对于理解程序的运行情况是十分有益的。并且在当前许多程序中,为了实现程序的高效运行,都使用了汇编语言,在了解了函数栈帧切换原理后,对于理解这些汇编也是非常有帮助的。
参考链接:
https://hanleylee.com/compile-of-ios–project.html
https://mp.weixin.qq.com/s/FSlJKnC0y51nsLDp1B3tXg
https://zhuanlan.zhihu.com/p/27339191
https://juejin.cn/post/6844903970993864711
原文地址:https://blog.csdn.net/wujianing_110117/article/details/122033576
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.7code.cn/show_27996.html
如若内容造成侵权/违法违规/事实不符,请联系代码007邮箱:suwngjj01@126.com进行投诉反馈,一经查实,立即删除!