栈是一块内存空间,会从高地址向低地址增长,同时在函数调用过程中,会通过栈寄存器来维护栈帧相关的内容。函数运行时,栈帧 (Stack Frame) 非常重要,包含了函数的局部变量以及函数调用之间的传参。
简介
这里的介绍都是以 x86_64 为基础,而栈帧的操作大部分是与寄存器相关,不同的架构使用寄存器的方式略有区别。
addr contents running access comments
High +-----------------+
| | ~ StackTop ~ |
| +-----------------+ -----
| | rbp(start) | |
| +-----------------+ |(main)
| | ARGS9 ~ ARGS7 | V 16(%rbp) ~ 32(%rbp) <- caller将参数压栈
| +-----------------+ -----
| | ReturnAddress | | 8(%rbp) <- call指令默认压栈操作
| +-----------------+ |
| | rbp(main) | | (%rbp) <- callee负责保存上个函数栈基址方便恢复
| +-----------------+ -----
| + ARGS6 ~ ARGS0 + |
| +-----------------+ | (foobar)
V | LocalVariable | V -4(%rbp)
Low +-----------------+ -----
栈帧的格式基本如下所示,$rsp
寄存器保存了当前栈的地址,可以通过 pushq
popq
call
等指令进行隐式操作,通过 $rbp
保存栈帧的地址,并进行相对寻址。
如果是多线程,可以通过 thread apply all backtrace
命令查看所有线程的栈。
寄存器
在测试阶段,通常不会开启优化,所以,直接查看变量即可。对于线上的代码,通常需要开启代码优化,因此需要在调试时注意寄存器的使用情况。
$rip 指令寄存器,指向当前执行的代码位置
$rsp 栈指针寄存器,指向当前栈顶,可以通过pushq popq进行自动操作
$rbp 栈帧指针,用来标示当前栈帧的起始位置;
$rax $rbx $rcx $rdx $rsi $rdi $rbp 通用寄存器
$r8 $r9 $r10 $r11 $r12 $r13 $r14 $r15
%rdi
%rsi
%rdx
%rcx
%r8
%r9
六个寄存器用于存储函数调用的前六个参数,超过则通过栈传递;%rax
用来返回结果。
另外,需要区分 “Caller Save” 以及 “Callee Save” 寄存器,在某个函数中,会使用到通用寄存器,那么在子函数中这些寄存器的值可能被覆盖,所以需要确定寄存器的保存方式。
函数传参
在具体的 CPU 硬件中,函数的运行需要借助硬件的栈 (Stack) 能力,为了保证各个模块的函数直接可以相互调用,那么就需要遵守 Calling Convention,这也是 ABI (Application Binary Interface) 的一部分。
详细的可以通过 man syscall
查看,如下的示例中,都是以如下函数作为测试。
int foobar(int a, int b, int c, int d, int e, int f, int g, int h, int i)
{
return a + b + c + d + e + f + g + h + i;
}
int main(void)
{
return foobar(1, 2, 3, 4, 5, 6, 7, 8, 9);
}
汇编
可以通过 gcc -S main.c
查看对应的汇编代码。
foobar:
pushq %rbp # 保存上次的栈
movq %rsp, %rbp # 同时使用rbp进行栈的快速操作
movl %edi, -4(%rbp) # 将通过寄存器传递的参数保存在栈中
movl %esi, -8(%rbp)
movl %edx, -12(%rbp)
movl %ecx, -16(%rbp)
movl %r8d, -20(%rbp)
movl %r9d, -24(%rbp) # 到此为止
movl -8(%rbp), %eax # 开始加法计算,edx保存了计算结果
movl -4(%rbp), %edx
addl %eax, %edx
movl -12(%rbp), %eax
addl %eax, %edx
movl -16(%rbp), %eax
addl %eax, %edx
movl -20(%rbp), %eax
addl %eax, %edx
movl -24(%rbp), %eax
addl %eax, %edx
movl 16(%rbp), %eax # 这里是通过栈传递的参数
addl %eax, %edx
movl 24(%rbp), %eax
addl %eax, %edx
movl 32(%rbp), %eax
addl %edx, %eax # 计算最后一次加法同时将结果保存在eax中
popq %rbp # 恢复main的栈
ret
main:
pushq %rbp # 将$rsp减一个指针长度(8Bytes),并将$rbp的值写入到rsp指向的地址处
movq %rsp, %rbp # 将$rsp赋值给rbp寄存器,完成main栈帧的保存
subq $24, %rsp # 需要通过栈传递三个参数,每个参数占用8Bytes(实际有效的是高4Bytes)
movl $9, 16(%rsp)
movl $8, 8(%rsp)
movl $7, (%rsp)
movl $6, %r9d # 剩余的6个参数通过寄存器进行传递
movl $5, %r8d
movl $4, %ecx
movl $3, %edx
movl $2, %esi
movl $1, %edi
call foobar # 将地址添加到栈顶
leave
一般 Linux 下会优先将参数压到寄存器中,只有当寄存器不够所有的参数时,才会将入参压到栈上,一般入参的压栈顺序为 $rdi
$rsi
$rdx
$rcx
$r8
$r9
,返回值通过 $rax
传递。
如上的示例中,第 7 8 9 个参数会通过栈进行传递,也就是从 $rsp
向下的地址
另外,可以看到函数的入参方式是从右到左。
从汇编看,完成一个函数调用关键执行就是 call
pushq
popq
ret
leave
等指令。
寄存器
可以通过如下方式验证上面的内容。
(gdb) info registers # 可以简写为i r
rax 0xe 14
rbx 0x0 0
rcx 0x4 4
rdx 0x3 3
rsi 0x2 2
rdi 0x1 1
rbp 0x7fffffffe0c0 0x7fffffffe0c0
rsp 0x7fffffffe0c0 0x7fffffffe0c0
r8 0x5 5
r9 0x6 6
... ...
(gdb) x/10xw 0x7fffffffe0c0
0x7fffffffe0c0: 0xffffe0f0 0x00007fff 0x004005cd 0x00000000
0x7fffffffe0d0: 0x00000007 0x00000000 0x00000008 0x00000000
0x7fffffffe0e0: 0x00000009 0x00007fff
如上为小端地址,所以包括了 $rbp
地址 0x7fffffffe0f0
,返回地址为 0x4005cd
,以及参数 7 8 9 。因为,入参为 4 字节,所以高位实际上是无效的,可能含有脏数据。
另外,可以直接通过 info frame
查看当前栈的信息,包括了参数信息。
获取中断号
有些时候会在中断处发生死锁,但是很难确认中断号是多少,例如如下的示例。
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
void foobar(int sig)
{
fprintf(stdout, "got signal %d.\n", sig);
}
void handler(int sig)
{
foobar(sig);
}
int main()
{
if (signal(SIGIO, handler) == SIG_ERR) {
fprintf(stderr, "register handler for SIGIO failed, %d:%s.", errno, strerror(errno));
return -1;
}
while(1);
return 0;
}
通过 gcc -o foobar foobar.c -O0 -g
进行编译,如果使用 -O2
或者默认,有可能设置的断点地址不匹配。
有的时候,如果存在信号不安全的函数,那么就可能会发生死锁,而此时通过 gdb 获取栈时,会发现在某个栈帧处,有如下的信息。
(gdb) bt
#0 0x00000000004004dc in foo ()
#1 0x00000000004004f8 in signal_handler ()
#2 <signal handler called>
#3 0x000000000040050d in main ()
但是没有提供具体的参数信息,主要是如果要 gdb 打印信息,那么必须要知道参数个数、各个参数大小等信息。
不过还好,我们知道信号处理函数的入参及其大小,也就是只有一个用来标示那个信号的参数,可以直接切换到 frame 1
,然后通过上述方式查看。
如果是多线程,同时又使用了信号的同步机制,那么就可能会在不同的线程中出现多个信号处理。