C++内存模型
- 堆 heap
- 栈 stack
- 全局/静态存储区 (.bss段和.data段) :全局和静态变量被分配到同一块内存中。在C语言中,未初始化的放在.bss段中,初始化的放在.data段中;在C++里则不区分了。
- 常量存储区 (.rodata段)
- 代码区 (.text段)
最近学GDB调试看汇编指令的时候,读到一篇很好的文章,分享一下
原文:https://www.jianshu.com/p/9f547d8428c3
C函数的调用机制
(一) 堆(Heap)和堆栈(Stack)
- 堆栈(Stack)亦称为栈,能够在函数运行之前自动分配足够的空间资源,函数运行完毕后自动回收资源。
- 堆(Heap)的空间资源不同于栈,要获取它必须由程序员手动申请,然后由操作系统根据一定的算法进行分配。操作系统只有在进程结束时会自动回收该进程对应的堆空间资源,不过最好由程序员手动释放资源。
day2中也说了
(二) 代码段(Text Section)
每个函数经过编译生成的二进制机器指令皆存储在内存空间中的代码段。
(三) 栈帧(Stack Frame)
上文提到,栈能够为函数运行分配足够的空间资源,这种资源便称为栈帧。
下面这些陈述非常重要,务必要记住,不然怎么看汇编代码
栈帧的分配是从高地址向低地址逐步执行的。
一个栈帧大小不是无限的,最靠近低地址的一端称为栈顶,最接近高地址的一端称为栈底,栈顶地址和栈底地址各自保存在专门的寄存器里边,这两个专门的寄存器存放的值都是地址,故亦可分别称之为栈顶指针esp、栈底指针ebp。
一个栈帧栈底地址减去栈顶地址所得的值决定了该栈帧的大小,可以通过让栈顶指针esp自增与自减分别控制栈帧的缩小与扩大。
一个函数栈帧的结构如图 所示(下文会提到返回地址),假设该函数局部变量个数不为 0,且有调用其他函数。
(四) 堆栈指针寄存器和基址指针寄存器
堆栈指针寄存器 和 基址指针寄存器 都属于通用寄存器。
- 堆栈指针寄存器用来存放栈帧的栈顶地址,根据数据位数不同可以分为三种,16位的 sp,32位的 esp,64位的 rsp,为说明方便,下文以 esp 为例进行阐述。
- 基址指针寄存器用来存放栈帧的栈底地址,根据数据位数不同可以分为三种,16位的 bp,32位的 ebp,64位的 rbp,为说明方便,下文以 ebp 为例进行阐述。
(五) 指令寄存器 ip
该寄存器总是存放下一条执行 指令 的所在地址。
(六) 入栈指令 push 和出栈指令 pop
push 和 pop 都属于 汇编指令。
- 入栈操作分为两步。第一步栈顶指针自减以扩大栈帧空间;第二步,将某个寄存器的值保存新开辟的位置上。
- 出栈操作只有一步。第一步,栈顶指针自增以缩小栈帧空间,将原先最靠近栈顶的值赋予某个寄存器。
(七) 函数调用指令 call
call 也属于汇编指令。
- 调用一个函数时,一定会执行 call 指令,汇编中调用 printf 函数的写法如下。
call printf
- call 指令包括两个步骤,第一步是让当前指令寄存器 ip 的值入栈(也就是下一条指令的地址),作为返回地址,第二步是将指令寄存器 ip 的值 修改 为接下来即将调用的函数 第一条机器指令的所在地址,从而实现跳转。
(八) 函数参数入栈顺序
函数参数入栈顺序为从右到左。
func(1, 'A', 3.14);
该函数参数入栈顺序为 3.14,'A',1。
(九) 不同函数的机器指令段的共性
每个函数的机器指令段的开头,都有以下几步操作:
- 第一步,在栈帧中 保存上一栈帧的栈底地址,汇编指令为
push ebp
。 - 第二步,将上一栈帧的栈顶地址 作为 当前函数栈帧的栈底地址,汇编指令为
mov ebp, esp
。 - 第三步,为当前函数的局部变量开辟足够的空间,汇编指令为
sub esp, M
,M 为局部变量占用栈帧空间的字节数。
每个函数的机器指令段的末尾,都有以下几步操作:
- 第一步,将 esp 恢复为为局部变量开辟空间之前的值,汇编指令为
mov esp, ebp
,恢复后,esp 的值恰好是上一栈帧栈底地址的地址。 - 第二步,将 ebp 恢复为上一栈帧的栈底地址,汇编指令为
mov ebp, [esp]
,恢复后,esp 的值恰好是存放返回地址的地址。 - 第三步,将 eip 恢复为 call 指令第一步骤所操作的值,汇编指令为
mov eip, [esp]
,恢复后,esp 的值恰好为刚执行完的函数的第一个形参的入栈地址。 - 第四步,将 esp 值恢复为为刚执行完的函数的参数开辟空间之前的值,汇编指令为
pop ...
,恢复后,esp 的值恰好是当前栈帧最靠近 0 地址的局部变量的地址。
(十)C/C++ 函数调用过程剖解
#include <stdio.h>
int main(void)
{
int apple = 10;
int pear = 20;
int total = 0;
printf("apple = %d, pear = %d.n", apple, pear);
total = apple + pear;
return 0;
}
printf 函数调用之前,参数从右向左入栈。
调用 call 指令,此时存储在指令寄存器 ip 中的值是 printf 函数下一条语句 total = apple + pear; 对应的机器指令的地址,该地址入栈,同时指令寄存器 ip 的值修改为 printf 函数在代码段中的第一条指令的地址。
根据“(九)”可知,开始执行 printf 函数时,会进行三步操作:
- 在 printf 函数栈帧中保存 main 函数栈帧的栈底地址;
- 将 main 函数栈帧的栈顶地址作为 printf 函数栈帧的栈底地址;
- 为 printf 函数的局部变量开辟足够的空间。
三步操作执行完之后便开始执行 printf 函数的主体机器指令段。
根据“(九)”可知,printf 函数的主体机器指令段执行完毕后,便开始收尾工作:
- 将 esp 恢复为为 printf 函数局部变量开辟空间之前的值;
- 将 ebp 恢复为 main 函数栈帧的栈底地址;
- 将 eip 恢复为语句 total = apple + pear; 对应的机器指令地址;
- 将 esp 值恢复为为 printf 函数的参数开辟空间之前的值,恢复后,esp 的值恰好是 total 的地址。
继续我们的汇编征程
常见指令分析(l代表32位)
间接寻址:操作数(立即数)放在RAM某个单元中,该单元的地址又放在寄存器R0或R1中。
movl %eax, %edx 等价于 edx = ebx //寄存器寻址(操作的都是寄存器 和内存不打交道 %就是操作寄存器)
movl $0X123, %edx 等价于 edx = 0x123 //立即寻址 以$开头的就是立即数
movl 0x123, %edx equal to edx = *(int32_t*)0x123 //直接寻址,把内存地址0x123中的内容赋值给ebx
movl (%ebx), %edx equal to edx = *(int32_t*)ebx //间接寻址
// 所以这句命令的意思是 把内存地址ebx中的内容赋值给edx
movl 4(%ebx), %edx equal to edx = *(int32_t*)(ebx+4)//变址寻址
//把内存地址ebx+4中的内容赋值给edx
pushl %eax 等价于做了下面这些事:
subl $4, %esp //4是立即数 立即寻址,让栈顶指针的值减4,栈顶指针是寄存器,存储栈顶的地址
movl %eax,(%esp)//间接寻址 *(int32_t*)esp = eax
总结:
pushl 先扩大栈空间 再放进去数据
popl %eax 等加于:
movl (%esp),%eax //eax = *(int32_t*)esp
addl $4, %esp //栈缩小
总结:
把数据弹出后 再缩小栈空间
eip寄存器,用来存储CPU要读取指令的地址,CPU通过EIP寄存器读取即将要执行的指令。每次CPU执行完相应的汇编指令之后,EIP寄存器的值就会增加。
call 0x123 //函数调用 等价于:
pushl %eip //执行被调用函数前先把cpu将要执行的指令保存一下
movl $0x123,%eip //然后再把被调用函数地址赋值给寄存器eip
为什么要这么做,不是多此一举吗?为什么不直接把$0x123赋值给eip?
原因是:
寄存器eip存储着cou将要执行的指令,是它在指导cpu要做什么,
就好比你在玩游戏,接下来要和你对象打电话了,突然你妈妈让你去做饭,
你要先找个笔记一下(保存)你接下来要和你对象打电话,
然后再去做饭做完饭回来看看本子,哦接下来我应该打电话了
ret 等价于:popl %eip
movl %esp,%ebp
这就是把栈底指针移动到栈顶指针位置
注意eip的值只能被间接修改不能直接修改
Linux下默认是使用AT&T格式来书写汇编代码,Linux Kernel代码中也是AT&T格式,我们要慢慢习惯使用AT&T格式书写汇编代码。
这里最需要注意的AT&T和intel汇编格式不同点是:
AT&T格式的汇编指令是“源操作数在前,目的操作数在后”,而intel格式是反过来的,即如下:
AT&T格式:movl %eax, %edx
Intel格式:mov edx, eax
表示同一个意思,即把eax寄存器的内容放入edx寄存器。
这里需要注意的是AT&T格式的movl里的l表示指令的操作数都是32位
类似的还是有movb,movw,movq,分别表示8位,16位和64位的操作数。
int Add(int a,int b)
{
return a+b;
}
int main()
{
Add(7,9);
return 0;
}
输入命令后产生main.s文件:
gcc -m32 -S main.c
接下来分析main.s文件(以.开头的代码是链接时候用的 可以直接删除进行分析)
eax 暂存一些数值
下面这三个指令建议看看上面函数调用机制的“(九)”
你就知道是做什么的了
pushl %ebp
movl %esp, %ebp
popl %ebp
Add:
pushl %ebp
movl %esp, %ebp//前两行 函数固定套路
call __x86.get_pc_thunk.ax //?
addl $_GLOBAL_OFFSET_TABLE_, %eax
movl 8(%ebp), %edx//应该是把ebp的地址+8 之后的地址里面的值放进edx ,
//为什么这么说,因为 第二个命令 ebp = esp了已经,而再main中push9和7的时候
//就是通过先让esp减4 再把值放到对应的内存空间
//那为什么不是 ebp加4而是把ebp加8的内容赋值给edx?看第一个命令 pushl %ebp,这个命令也让esp加4
//所以一共esp加了12(main中pushl两次,Add第一行pushl一次)
movl 12(%ebp), %eax//同上 所以经过上面的推测 这个eax的值应该是9 edx的值是7
addl %edx, %eax //两个数向加 证明我的猜想是对的 然后打印了一下寄存器的值 果然没错,图片在下面
//info registers显示所有的寄存器;info registers eax 可简写为i r
// addl 表示将%edx+%eax的结果存放进寄存器edx中
popl %ebp
ret
main:
pushl %ebp//保存ebp的值
movl %esp, %ebp //该命令执行后ebp的值等于esp的值,他们都是寄存器,存储内存地址,相当于指针
call __x86.get_pc_thunk.ax //?
addl $_GLOBAL_OFFSET_TABLE_, %eax
pushl $9 //push的时候 先扩大栈帧空间 再放进去值 也就是esp值先减4,再把9放进给此时esp指向的内存空间
pushl $7 //实参入栈顺序先9再7 也证明了上面分析函数调用机制的正确性
call Add//万事俱备 调用函数Add 调用的时候发生了什么还记得吗?
//上面将的很清楚了,你和女朋友打电话的例子
//既然调用Add了 就别往下看了 网上看 去看Add发生了什么
addl $8, %esp//esp加一个数等价于缩小栈帧空间
movl $0, %eax
leave
ret
接下来表演一套猛如虎的操作:
gcc -m32 -g main.c
gdb a.out//运行gdb
layout asm//开启汇编页面
b main//添加断点
run
si//执行下一个命令
对比上面汇编 进行的分析