序
刚学计机的第一年,虽然 “桟” 这个词经常从老师嘴里冒出,其实他们都没有真正解释过这个东西到底是什么,当然对于大部分学上层的人来说,不知道是无所谓,特别是如果学 Java ,基本上接触不到这个概念。
这篇文章呢就以桟展开,然后说说函数之前的参数是怎么配合桟来实现的,看完这篇文章,无论是在 x86 还是 ARM 架构下面,都应该可以写出汇编和C语言的混合调用的代码,这也是在内核实现中不可缺少的一部分。
桟的本质
桟,就是一块可用的内存,它只是规定了数据拿出和放进的顺序,这个顺序就是我们经常说的,先进后出,在上课的时候老师总会用一个桶来比喻,先放进去的东西总是在下面,所以得把后面放的东西拿出来,才能拿最早放进去的。
其实这个顺序为什么是这样,根本不重要,关键在于什么呢,数据是有序放进去的,也就是说拿出来的时候一定也是有序的,它跟数组没有区别,只是我们要拿到第一个元素的时候,是到最后面去拿。但是,实际上我们也不一定遵循这个顺序,有时候我们甚至可以直接抛弃一块连续的空间,比如后面抛弃局部变量空间的时候。
在 x86 的体系下,有一个专门的寄存器叫,sp( stack pointer ),它存储了一个地址,就是桟顶的地址,更准确的说,是最后一个放进去的数据的地址
sp[0] = 最后一个放进去的元素
sp[1] = 倒数第二个放进去的元素
...
在初始化的时候,无论是操作系统的初始化,又或是普通程序初始化,其中要做一件事是,分配一块区域,让 sp 指向区域最末端
void * ptr = malloc(4KB);
sp = (int)ptr + (1 << 22);
// sp = 0x400000, 假设分配的区域就是 0~0x3fffff
那么 sp[0] = ?
!注意 这里会造成内存越界
我们说了,sp[0] 指向的是最后一个放进去的数据,现在放数据进去了吗?
很简单的道理, sp[0] 访问的是 0x400000~0x400003 这4 字节
这里假设机器是 32bit
而一块区域并不在我们分配的区域里面
int i = 1;
int * s_sp = (int *)sp;
s_sp--;
*s_sp = 1;
sp = (int)s_sp;
注意这里把 sp 看成是一个整形的变量,实际上
也是如此,指针这个概念只有C才存在
上面代码等同于
push $1 // 把立即数 1 入栈
也等同于
sub $4, %sp // sp = sp - 4;
mov $1, (%sp)
此时的 sp == 0x3fffffc
上面的代码说明了 sp 永远指向的地方都是存放数据的地方,我们用 [sp] 访问的是存在数据的地方,当我们想加入新数据的话,也就是 push 操作,必须得把 sp -= 4 也就让它的地址指向一块当前没有数据的地方,然后在放入数据,这样 sp 再次指向了最后一次放入的数据。
那么同理也可以知道,如果执行 pop 操作
sp 和 esp 的意义是一样的,读者可以暂时不要区分
pop():
int * p = (int *)sp;
int data = *p;
sp = sp - 4; // or sp = (int)(--p);
pop:
movl (%esp), %reg
subl $4, %esp;
<=>
popl %reg
也就是说,先取数据在改变 sp
pop 之后,并不会请空无效数据(data)的值,stack pointer已经标示了当前有效数据就是在它之上的数据 其余数据是什么并不关心,所以在C中,一些局部变量,我们虽然在函数体之外,仍然可以访问到。
最后总结一下,栈就是指数据取出和放入遵循一定规则的区域,没有什么神秘的地方。然后,sp 指针指向的是最后一个放入数据,如果没有放入数据就访问,会导致内存越界。
栈的作用
在理解栈这个东西之后,我们就可以来阐述它存在的意义了。
在程序中,我们会用到很多的变量,也有很多的函数调用,其中绝大多数的变量都是局部变量,这些变量的存在周期只存在于函数调用期间,我们把这种变量称之为局部变量,接下来我们了解到,函数调用是怎么实现返回的,以及为什么局部变量只会在函数内有效,还有函数调用之间的参数如何传递。
局部变量
变量,就是一块内存空间,大小取决于它的数据类型本身的大小,而局部变量,其实就是它所在的空间正好就是桟里面。
void foo()
{
int i=0,j=1,k=2;
i--;
j++;
k--;
...
...
return;
}
foo:
push $0
push $1
push $2
decl (%esp+8)
incl (%esp+4)
decl (%esp)
...
...
addl $12, %esp // 此时桟顶数据已经不再包括i,j,k了
ret // 函数返回
代码不难理解,下面就是汇编代码,为什么局部变量在函数体之外就不能访问呢,原因很简单,当我们函数返回之前,分配给局部变量的空间我们回收了,回收是通过改变桟指针,sp,来实现的(注意我们的桟是从高地址到低地址),之前也提到了,sp指向的空间以下的空间都是无效的数据,所以我们认为 i,j,k 这几个变量已经无效了,所以它们被称为局部变量。
所谓局部变量,只是桟的一块空间,当我们离开函数之后,桟顶指针就不在指向它们了,读者可能会发现,其实离开函数之后,用 [sp-4] 不就可以访问了,对的,所以我们说这不是严格意义上的回收,但是访问局部变量是有自己的规范的。
函数参数传递
函数的参数和局部变量是等价的,当我们调用一个函数之前,就把它的参数放在了桟里,让调用的函数可以使用。
void bar(int i, int j, int k);
void foo()
{
int i=0,j=1,k=2;
...
bar(i,j,k);
...
...
return;
}
foo:
// 局部变量的入桟
push $0
push $1
push $2
...
// 参数入桟 拷贝了一遍局部变量
movl (%esp), %reg
push %reg //k入桟
movl (%esp+4), %reg
push %reg //j入桟
mov (%sp+8), %reg
push %reg //i入桟
// 调用函数
call bar
...
所以说局部变量的本质和传递的参数都一样,它们都是桟上的空间。有趣的一个地方是,参数是从右至左的顺序入桟。其实就是一个习惯,稍后会解释。
上面的代码很清楚解释了一个道理,就是为什么函数里面修改传递来的参数对调用方是没有影响的,因为在调用之间,要传递的参数都做了拷贝,被调用的函数只是对拷贝的那一份做了修改,并不改变原来的那一份。有人说引用可以,其实引用本质就是指针。
函数调用的本质
函数就是一块代码的首地址,和我们C语言使用的 goto 是没有区别的。
这是必须得明白的一件事,当我们函数返回的时候,就是 goto 到了原来调用它的地方的下一条代码。那么调用之前,我们得做的事情有什么呢,首先是调用的参数得入桟,然后把函数返回需要到的地方的地址入桟。接着,跳转到目标函数的地址执行。
void bar(int i) {
...
}
void foo() {
...
bar(2);
...
}
bar:
0xb00 ...
....
0xbef ret
foo:
0xa00 ...
0xa04 push $2
0xa08 call bar
0xa0c ...
上述的意思就是 foo 的函数代码所在地址 0xa00
同理 bar 在 0xb00
那么 call 到底做了什么事情
首先 push $0xa0c 也就是返回的时候要执行跳转到的地方( goto )
然后 jmp 0xb00 // goto bar
即直接 goto 目标代码所在地方执行
ret 呢
应该不难猜出 应为已经知道要回到哪个地方了
首先在 ret 之前得保证 sp 跟进来的时候指向的是同一个地方
明确一点, 栈 是 所 有 函 数 公 用 的, bar函数也会使用 sp
那么如何保证它一定回到最初的位置呢,答案是做备份
下面介绍一个新的寄存器 bp
在所有函数入口和出口都有这样类似的指令
function:
pushl %ebp // bp 也是全局的 要备份
movl %esp, %ebp // bp = sp
...
... 中间局部变量的存在导致 sp 必然会改变
...
movl %ebp, %esp // 让 sp = old_sp, 复原
popl %ebp // bp = old_bp
ret
也就是说,bp 寄存器的作用就是备份函数进入时候的栈顶指针,然后 bp 本身放进了栈里面备份。然后无论我们函数中间 sp 怎么变,我们都可以保证最后它一定是进来时的位置。
了解了 bp 和 sp 的备份机制
ret 的作用现在继续
刚才说了,调用函数之前除了要将函数要的参数入栈,
call 还会把调用函数的下一条指令入栈
这一步是call 指令自动完成的
所以 ret 之前,我们恢复了 sp 最开始的位置
sp 指向的是 最 后 一 个 入 栈 的 元 素
即调用函数前,最后一个 自动入栈的 下 一 条 指 令 地 址
ret::
popl %reg // 把地址拿出来
jmp %reg // goto 到目标地址
以上面的例子, reg = 0xa0c
call 和 ret 做的事情,利用无条件跳转就可以轻松实现对吧,关键得理解,函数地址就是一个代码的起始地址,每一条指令都是有地址的。
现在,我们又要介绍一个新的寄存器,它储存的地址就是 将要执行的代码的地址
foo:
0xa00 ...
0xa04 push $2
0xa08 call bar
0xa0c ...
当 CPU 执行 0xa04 指令的时候
还有一个 ip ( instruct pointer ) 寄存器,指向的就是 0xa08
我们上课会学到的 PC 指针,它就是本尊啦
也就说 call 其实是这么做的
pushl %eip
jmp 0xxx 目标代码的地址
这些寄存器是什么都不重要,关键我们得理解其中栈的作用,就是作为一个备份的区域,存函数参数,放了调用函数之后该回到的地方 以及 函数本身要使用的局部变量。
接下来 以下一个例子 来一个总结
int func(int arg1) {
int local;
arg1++;
return arg1;
}
void caller() {
int ret;
ret = func(1);
}
caller:
// 准备阶段
pushl %ebp
movl %esp, %ebp
// 开辟 ret 局部变量的空间
subl $4, %esp
// 参数入栈
pushl $1
call func
// 存储返回值到本地变量中
movl %eax, [sp+4]
// 还原状态
movl %ebp, %esp
popl %ebp
ret
调用 函数之前 和 函数之后 的 sp 一定是一样的,所以 sp 指向的一定是最后一次 push 导致 sp 下移的位置。
还有一些前面没有说过的东西,返回值是怎么实现的?就是通过一个 ax 的寄存器,然后发现了吗,我们直接用 mov 指令来索引本地变量,再次说明,栈只是遵循一个特定顺序的存储空间,甚至,假设还有一个本地变量,我们还可以,用
之间我们说的,入栈出栈的顺序根本没有遵守,它就是一块可用的空间。
int func(int arg1) {
int local;
arg1++;
return arg1;
}
func:
// 准备阶段
pushl %ebp
movl %esp, %ebp
// 开辟 local 局部变量的空间
subl $4, %esp
// [bp] 是 bp的备份, [bp+4] 是函数返回地址
incl (%ebp+8)
movl %(ebp+8), %eax // 返回值
// 还原状态
movl %ebp, %esp
popl %ebp
ret
很明显啦, bp 用来访问传入的参数,sp 用来访问局部变量,但是它们都是栈上的变量。
传入 arg1 = 1 即是 bp + 8 指向的位置
x86 arm 的区别
前面说的参数的传入以及局部变量的储存,都是 x86 上的函数调用规则,是有一套协议的,下面给上具体的反汇编代码了,之前的都是伪代码。
int a[5] = {1,2,3,4,5}; /* 数组, 全局变量 */
int func(int a) {
return a;
}
int d(int * a, int b, int c, int d, int e, int f) {
(*a)++;
func(b);
return e+f;
}
int main(void) {
d(a, 1,2,3,4, 5);
return 0;
}
ARM 就不一个一个分析了,关键在于参数直接用寄存器和桟传递的,然后返回值也是如此,然后局部变量的位置有所不同,其实就是函数调用的规范不一样。
.data
a:
.word 1
.word 2
.word 3
.word 4
.word 5
.text
func:
str fp, [sp, #-4]!
add fp, sp, #0
sub sp, sp, #12
str r0, [fp, #-8]
ldr r3, [fp, #-8]
mov r0, r3
add sp, fp, #0
ldr fp, [sp], #4
bx lr
d:
push {fp, lr}
add fp, sp, #4
sub sp, sp, #16
str r0, [fp, #-8]
str r1, [fp, #-12]
str r2, [fp, #-16]
str r3, [fp, #-20]
ldr r3, [fp, #-8]
ldr r3, [r3]
add r2, r3, #1
ldr r3, [fp, #-8]
str r2, [r3]
ldr r0, [fp, #-12]
bl func
ldr r2, [fp, #4]
ldr r3, [fp, #8]
add r3, r2, r3
mov r0, r3
sub sp, fp, #4
pop {fp, lr}
bx lr
main:
push {fp, lr}
add fp, sp, #4
sub sp, sp, #8
mov r3, #5
str r3, [sp, #4]
mov r3, #4
str r3, [sp]
mov r3, #3
mov r2, #2
mov r1, #1
ldr r0, .L7
bl d
mov r3, #0
mov r0, r3
sub sp, fp, #4
pop {fp, lr}
bx lr
.L7:
.word a
给出调用的桟
在x86当中,默认的push指令就是Decreasing Before模式,但是在Arm中没有原生,对于push, pop的指令,实际上还是会转换到ldr,str或者它的衍生指令。之所以出现STMDA的栈指令,就是为了操作方便,比如你存进去栈的时候是 Full Descending 那么取出来的时候就是 Empty Ascending
总结
到如此,我的耐心已经消耗殆尽拉0.0,给出俩张图,读者自己好好理解,所以我很佩服热衷分享的人,并且还能说的很详细的人!
x86
ARM