栈
在编译期间,编译器会计算出一个函数中的所有局部变量需要使用多少的内存.这段内存称为函数的栈.
在执行一个函数的时候,会在两个寄存器中分别存有两个指针.
-
栈底指针,指向这段内存的起始位置.在riscv指令集,这个指针通常存在寄存器
s0
中. -
栈顶指针,指向这段内存的结束位置.在riscv指令集,这个指针通常存在寄存器
sp
中.
这个栈是从高地址往低地址数的,也就是说栈顶指针的值其实比栈底指针小.他们的关系是
栈底指针 - 栈大小 = 栈顶指针
假如有函数fun1()
调用了fun2()
.
那么当执行到fun2()
的时候,fun2()
的栈底指针等于fun1()
的栈顶指针.
也就是有这样的关系(假设fun1
的栈底指针是x
):
地址 | 含义 |
---|---|
x |
fun1 的栈底指针 |
x-fun1的栈大小 |
fun1 的栈顶指针 |
x-fun1的栈大小 |
fun2 的栈底指针 |
x-(fun1的栈大小+fun2的栈大小) |
fun2 的栈顶指针 |
疑惑
变长数组存储在哪
我在刚学习编译原理的时候,错误地认为每个栈的大小都是在编译期就已经计算好固定了的.
所以我一直对C99的变长数组感到疑惑.
如果栈的大小是编译期确定的,也就是变长数组不能存储在栈上,那么它在哪?
如果变长数组位于栈上,那么它在栈上是怎么分布的,如何被计算出来?难道是在运行期间才能确定栈的大小吗?
为什么编译器翻译出来的汇编常常使用栈底指针来表示变量的地址
如果我们阅读汇编代码,会发现编译器翻译出来的汇编代码常常使用栈底指针+偏移
的方式来指出栈上变量的位置.
只有在刚进入函数的时候,会用栈顶指针+偏移
的方式保存上一个函数的信息到当前栈上.
我曾经非常困惑,如果统一使用栈顶指针+偏移
不是更加便于理解吗?
结论
通过对变长数组的汇编代码的阅读, 我发现:
- 变长数组确实在栈上.
- 栈的大小会在运行期改变.这个时候会修改栈顶指针.
- 编译器在翻译的时候会先计算栈的大小
x
(这个大小不包含变长数组).最初进入函数时,会先分配一个大小为x
的栈. - 刚进入函数时,
栈顶指针 = 栈底指针 - x
- 分配了这个大小为
x
的栈之后,会计算变长数组的大小y
.然后直接以新的栈顶指针为变长数组的起始位置,向后再扩张y
字节的内存. - 最终,
栈顶指针 = 栈底指针 - (x + y)
- 栈底指针在这个过程中保持不变,因此我们可以一直用栈底指针来找到栈上变量.
也就是:
地址区间 | 内容 |
---|---|
[栈底指针-x, 栈底指针) |
函数中所有的局部变量(不包含变长数组) |
[栈底指针-(x+y), 栈底指针-x) |
函数中的局部变长数组(此时的地址区间[栈顶指针,栈顶指针+元素大小) 是第0个元素的位置) |
汇编句读
C语言
一开始我们写下这样的C程序
#include <stdio.h>
int fun(int x)
{
int a[x];
a[x-1] = 1;
return a[x-1];
}
int main()
{
int x;
scanf("%d", &x);
fun(x);
return 0;
}
汇编
如果使用命令riscv64-linux-gnu-gcc a.c -S -O0
生成a.s
文件,会发现这个文件里有一些让人难以理解的无用计算(计算的结果直接被覆盖,没有用于任何用途).
例如这一段,大概可以猜测这里做了一个128
位的乘法,但是计算出来的结果直接被覆盖.
# 如果元素大小位2的k次方字节,那么这些数字将会是61-k, 3+k, k
srli a0,t1,59 # a0 = t1 >> 59 取t1(也就是数组长度x)的最高5位,保存到a5的最低5位上
slli a3,t2,5 # a3 = t2 << 5 取t2(也就是0)的最低59位,保存到a3的最高59位上
or a3,a0,a3 # a3 = a3 | a0 将上面a5(最低5位有值)和a3(最高59位有值)组合起来,形成一个64位数字
slli a2,t1,5 # a2 = t1 << 5 取t1(也就是数组长度x)的最低59位,保存到a2的最高59位上
mv a3,a1 # a3 = a1 a3被数组长度x覆盖?
mv a6,a3 # a6 = a3
li a7,0 # a7 = 0
srli a3,a6,59 # a3 = a6 >> 59 取a6(也就是数组长度x)的最高5位,保存到a3的最低5位上
slli a5,a7,5 # a5 = a7 << 5 取a7(也就是0)的最低59位,保存到a5的最高59位上
or a5,a3,a5 # a5 = a3 | a5 将上面a3(最低5位有值)和a5(最高59位有值)组合起来,形成一个64位数字
slli a4,a6,5 # a4 = a6 << 5 取a6(也就是数组长度x)的最低59位,保存到a4的最高59位上
mv a5,a1 # a5 = a1 a5被数组长度x覆盖?
如果使用命令riscv64-linux-gnu-gcc a.c -S -Og
生成a.s
文件,产生的汇编就会精简这些意义不明的运算,同时又不会过于精简.
所以下面我们使用加上-Og
选项的汇编来讲解.
# 文件的信息:
.file "a.c"
.option pic
# fun1的描述信息:
.text
.align 1
.globl fun
.type fun, @function
# fun1的汇编:
fun:
addi sp,sp,-32
sd ra,24(sp)
sd s0,16(sp)
addi s0,sp,32
la a4,__stack_chk_guard
ld a5, 0(a4)
sd a5, -24(s0)
li a5, 0
slli a5,a0,2
addi a5,a5,15
andi a5,a5,-16
sub sp,sp,a5
addiw a0,a0,-1
slli a0,a0,2
add a0,sp,a0
li a5,1
sd a5,0(a0)
ld a3, -24(s0)
ld a5, 0(a4)
xor a5, a3, a5
li a3, 0
bne a5,zero,.L4
li a0,1
addi sp,s0,-32
ld ra,24(sp)
ld s0,16(sp)
addi sp,sp,32
jr ra
.L4:
call __stack_chk_fail@plt
# fun1的描述信息:
.size fun, .-fun
.section .rodata.str1.8,"aMS",@progbits,1
.align 3
# 字符串"%d"的信息:
.LC0:
.string "%d"
# main的描述信息:
.text
.align 1
.globl main
.type main, @function
main:
addi sp,sp,-32
sd ra,24(sp)
sd s0,16(sp)
la s0,__stack_chk_guard
ld a5, 0(s0)
sd a5, 8(sp)
li a5, 0
addi a1,sp,4
lla a0,.LC0
call __isoc99_scanf@plt
lw a0,4(sp)
call fun
ld a4, 8(sp)
ld a5, 0(s0)
xor a5, a4, a5
li a4, 0
bne a5,zero,.L8
li a0,0
ld ra,24(sp)
ld s0,16(sp)
addi sp,sp,32
jr ra
.L8:
call __stack_chk_fail@plt
# main的描述信息:
.size main, .-main
.ident "GCC: (Ubuntu 11.2.0-16ubuntu1) 11.2.0"
.section .note.GNU-stack,"",@progbits
我们摘出fun1()
的内容来看:
fun:
addi sp,sp,-32
sd ra,24(sp)
sd s0,16(sp)
addi s0,sp,32
la a4,__stack_chk_guard
ld a5, 0(a4)
sd a5, -24(s0)
li a5, 0
slli a5,a0,2
addi a5,a5,15
andi a5,a5,-16
sub sp,sp,a5
addiw a0,a0,-1
slli a0,a0,2
add a0,sp,a0
li a5,1
sw a5,0(a0)
ld a3, -24(s0)
ld a5, 0(a4)
xor a5, a3, a5
li a3, 0
bne a5,zero,.L4
li a0,1
addi sp,s0,-32
ld ra,24(sp)
ld s0,16(sp)
addi sp,sp,32
jr ra
.L4:
call __stack_chk_fail@plt
第一次分配栈内存
首先,一进入函数就执行以下指令为栈分配了32
字节的空间.并且保存了上一级函数的信息
addi sp,sp,-32 # sp = sp - 32 栈顶向下扩展32字节, 32字节是一个预计的大小,
sd ra,24(sp) # *(sp+24) = ra 保存返回地址到[sp+24, sp+32), 也就是将来的[s0-8, s0)
sd s0,16(sp) # *(sp+16) = s0 保存上一级函数的栈底指针到[sp+16, sp+24), 也就是将来的[s0-16, s0-8)
addi s0,sp,32 # s0 = sp + 32 计算出当前函数的栈底
目前栈上的内存如下:
位置 | 内容 |
---|---|
[s0-8,s0) |
返回地址 |
[s0-16, s0-8) |
上一级函数的栈底指针 |
[sp, s0-16) |
未使用 |
设置栈溢出标记
la a4,__stack_chk_guard # 取出栈溢出标记的地址,放到寄存器a4
ld a5, 0(a4) # 取出存放在a4中的地址所指向的8个字节内容,存到a5
sd a5, -24(s0) # 将a5中的标记保存到[s0-24,s0-16),
详情见另一篇文章{% post_link stack-chk-guard避免栈溢出攻击 %}
目前栈上的内容如下:
位置 | 内容 |
---|---|
[s0-8,s0) |
返回地址 |
[s0-16, s0-8) |
上一级函数的栈底指针 |
[s0-24, s0-16) |
栈溢出标记 |
[sp, s0-24) |
未使用 |
计算变长数组大小
li a5, 0 # a5 = 0
slli a5,a0,2 # a5 = a0 << 2(相当于a5=a0*4) 计算出数组大小,每个元素4字节
addi a5,a5,15 # a5 = a5 + 15
andi a5,a5,-16 # a5 = a5 & (-16) 这两步是一个位运算的技巧,作用是将a5变为16的倍数(向上取整)
这里的寄存器a0
保存了参数x
,这是riscv函数调用的习惯.
计算出数组的大小按照16
字节对齐.
第二次分配栈内存
sub sp,sp,a5 # sp = sp - a5
栈顶指针再向下扩张a5
个字节的大小.
目前栈上的内容如下:
位置 | 内容 |
---|---|
[s0-8,s0) |
返回地址 |
[s0-16, s0-8) |
上一级函数的栈底指针 |
[s0-24, s0-16) |
栈溢出标记 |
[sp+数组大小, s0-24) |
未使用 |
[sp, sp+数组大小) |
变长数组 |
操作变长数组
addiw a0,a0,-1 # a0 = a0 - 1 32位加法,计算x-1
slli a0,a0,2 # a0 = a0 << 2(相当于a5=a0*4) 计算出第x-1个元素相对于数组起点的偏移
add a0,sp,a0 # a0 = sp + a0 计算出第x-1个元素的地址,也就是[sp+a0,sp+a0+元素大小)
li a5,1 # a5 = 1
sd a5,0(a0) # *(a0) = a5 给第x-1个元素赋值
检查栈溢出标记
ld a3, -24(s0) # 取出之前的标记
ld a5, 0(a4) # 获取一个新的标记
xor a5, a3, a5 # 将原本的标记和新的标记对比,从而检查就标记是否被修改
li a3, 0
bne a5,zero,.L4 # 如果被修改,那么跳转到.L4
可以认为地址__stack_chk_fail@plt
存有一个处理检查异常的函数.
一旦检查不通过就会调用他来处理.
.L4:
call __stack_chk_fail@plt
返回
li a0,1 # a0 = 1
addi sp,s0,-32 # sp = s0 - 32 恢复第一次分配栈内存时的sp
ld ra,24(sp) # 取出之前保存的返回地址
ld s0,16(sp) # 取出之前保存的上一级函数的栈底地址
addi sp,sp,32 # 恢复sp指针到进入函数之前
jr ra # 跳转到回上一级函数