
本篇笔记是个人对x86 ISA的栈指针的探索整理。左职的cs专业课无论是计组汇编,体系结构,操作系统,编译原理全都是一堆笼统的概论,甚至四门课加起来都完全没有提过编译-汇编-链接-加载过程(CALL),没有pc寄存器,栈帧,栈指针的概念。也就是说C的底层功能完全没有涉及。
本文起因是笔者写一个小lab时有个要求“x86-64函数调用时%rsp指针要求按16字节对齐”,因为最开始没明白意思花了一点时间,但又对栈帧的实际运行逻辑产生了很大疑问。后来看61C中RISC-V的栈指针sp概念,RV只有一个sp指针,而x86有rsp rbp两个栈指针。于是便有此篇整理,资料查阅了CSAPP。
本文根本就是一个杂乱的整理,前后没有顺序,写的时候还不时增加内容。
x86的内存基本概念:所有进程使用虚拟内存,均以0x400000开始。(结构)从小地址到高地址分别是:代码段,静态变量段,堆段(malloc分配的内存)。最高地址从0x7fff...开始向下是栈段。虚拟内存的一个意义就在于所有进程都可以使用这样的同一套规则而不会有数据竞争。
RV中的pc寄存器等效于x86的rip,sp相当于rsp,但是也有rbp,实际的功能由二者共同实现。栈指针与程序计数器在汇编代码都不能随意改,使用gcc内联汇编改改rip有时还没事(但x86指令不是等长的,很容易出问题)。如果像我一样在对齐堆栈时直接把rsp的值,末尾抹成0然后什么也不管了,必出问题。segmentation fault已经算好的了,因为整个栈帧都歪了。不过也有可能直接被检查出来报错了。
保留寄存器为rbx,rsp,rbp,r12-r15七个。保留寄存器的概念为函数调用过程前后,这几个寄存器的值不变(调用过程中可以更改)。这样调用者在调用这些函数前,可以不必考虑这些寄存器会在函数调用后改变,如果函数需要保存当前状态,需要存栈的寄存器值较少,尤其是存入栈要使用rsp不可能改它吧。其他9个寄存器称为易失(volatile)寄存器,如果调用函数想保存这些寄存器则要存栈,只有需要才存不需要则不用。
保留寄存器也可能存栈,其目的是在函数返回前读出是保留寄存器在函数调用前后值不变。
函数调用时,rax为返回值,rdi,rsi,rdx,rcx为第1-4个传入参数,r8-r9为5,6个参数,更多参数为rsp+n*8,固定任何参数均用8字节存以方便对齐。调用时寄存器内容为参数值,rax同理。
rsp栈指针指向的是栈的结束位置,即栈最大值。栈指针的意义不在于限制栈大小,而在于基于栈指针寻址,栈指针与offset的结合指示了内存变量的地址。据测试,栈寻址使用rbp寄存器,即所有局部变量存栈取栈都用mov D,-offset(%rbp)的格式。
栈操作:push S 扩写为sub $8,%rsp ; mov S,(%rsp),pop类似之。x86是小端,栈向下增长,所以push是栈指针减8(字节)。rsp指针指向虚拟内存中的栈顶。
会发现一个问题,pop操作不会覆写原先位置的值,取出值后值仍在原位置,栈减小了。但实际上整块栈区域对于进程是始终具有读写权限的,也就是说不是严格的栈顶,栈从来不会被覆写删数据,这凭空增加工作量没有意义。栈帧也是一样的概念。有一个有趣的C程序示例就是基于栈帧变化导致输出错误,暂略。
栈增长的实际操作一是push,二是在函数调用开始时一次性减小足够大小的栈指针,注意在函数开始时已经把rbp存栈,rbp代表栈底即旧栈顶地址-8。栈收缩即将rsp收缩至现在rbp位置,然后从栈中pop出原rbp。
函数调用过程:先将返回地址存入rbp,然后易失寄存器存栈。此时进入函数,rsp存栈(push),置rbp为rsp,改rsp的值为新栈顶,存入局部变量(mov)。rsp只在函数调用开始时扩展,结束前恢复,其他的“存栈”操作为mov %eax,-8(%rbp)之类的操作。这就是栈帧。栈帧不是所有函数都需要,小的函数只用寄存器就够的不需要创建栈帧。
函数调用的pc(rip)相关:调用与jmp的本质都是改%rip,label与函数名都是被%rip替换。另外对于其他文件的函数调用,在单文件的编译汇编时跳转ip直接置空。链接是查找符号表为这些跨文件静态变量和函数填值的过程。加载是将二进制可执行文件加载入内存以待执行的过程。动态链接就是运行时链接,因为像libc这种东西大概率内存早就有了不需要再加载入内存,运行时找到内存中libc的位置然后往jmp里填地址就好了。这里还涉及到一个plt表的加速的问题,这个我有点兴趣再研究一下。
对齐:几乎所有ISA都要求,对于n字节大小的数据类型,其地址要根据n的倍数对齐,例如不可能得到以奇数字节为起始地址的int类型,如果你设置一个奇数地址的Cint* 指针,他会自动对齐。栈的扩展遵循按16字节对齐的规则,超出一个字节都会多分配一个16字节。
关于rbp:CSAPP没有谈这个,rbp应该是栈帧对应的栈底指针。RISC-V就没有rbp对应的寄存器(应该是吧)。rbp对齐要求同rsp。在x86中,进入函数调用首先将rsp存栈(push,此步更改了rsp的值),然后mov %rsp,%rbp。此时rbp指向分配的栈帧底,也相当于目前栈增长到的位置。然后减小rsp的值以扩展栈顶,所有寻址为“rbp相对寻址”。
局部变量如何存栈,哪些存哪些不存是编译器优化的问题,我测试是就看见一个没优化的c代码傻乎乎的把寄存器存栈紧接着又读出来。以上规范也是编译器在翻译为汇编代码时需要保证的。
通用视角:进程的状态可以被内存+寄存器唯一表示。内存只是个01串,只要可以完全可以把代码段读成数据,或者反过来什么的。很多复杂的设定根本意义上就是找地址。所以说地址是系统最重要的东西之一了。
最通用视角:打开浏览器搜索官方文档,比我写的这个详细明白准确的多。
funny test:
内联汇编改rbp,但编译器不会处理这个问题,所以包括push入的原rbp值和几个局部变量都改了地址,所以产生了很多错误,最后rbp爆了seg fault。爆seg fault的原因:main结束后发生的后续过程调用使用了错误的rbp寻址,该地址进程不具有读写权限。


一个反汇编的堆栈例子,可以看出:先把传入参数存在低地址,然后在高地址存局部变量。参数按高到低存,局部变量按低到高写。
