在 bprc 中,协程的创建和调度需要进行上下文切换。 所谓上下文就是程序在运行过程中特有的状态,主要包括包含局部变量的栈和各种cpu寄存器。 上下文切换本质上就是这些上下文的切换。 局部变量的栈内容在内存中,不会被替换,即切换时内容不会丢失,而且每个程序都复用CPU寄存器,所以每次切换时都要保存每个栈。 栈对应寄存器的值,而上下文切换的本质是如何保存寄存器的值——下一次执行栈时,先恢复CPU寄存器的值,再执行。
以调用函数为例,分为caller和callee。 在切换栈时,我们可以将寄存器存放在调用者中,也可以将调用者栈的寄存器存放在被调用者中。 因此,为了统一OS使用的寄存器,一般的CPU对寄存器都有规定的要求。
1.1. 寄存器 1.1.1。 x86-64 寄存器
在计算机体系结构的教科书中,寄存器通常被称为寄存器文件,它实际上是CPU上的一个存储区域,但他们更喜欢使用标识符而不是地址。
寄存器集成在CPU上,访问速度比内存快几个数量级。 有了更多的寄存器,GCC就可以用更多的寄存器来代替之前的内存栈,从而大大提高性能。
要将寄存器供自己使用,您必须了解它们的用途。 这些用途涉及函数调用。 x86-64有16个64位寄存器,分别是:%rax, %rbx, %rcx, %rdx, %esi, %edi, %rbp, %rsp, %r8, %r9, %r10, %r11, % r12、%r13、%r14、%r15。 在:
%rax 用作函数返回值。 %rsp 栈指针寄存器,指向栈顶 %rbp 栈帧指针,指向栈底 %rdi、%rsi、%rdx、%rcx、%r8、%r9作为函数参数,对应第一个参数和第二个参数依次。 . . %rbx, %r12, %r13, 用作数据存储, 遵循被调用者的规则, 随便用, 调用子函数前备份, 以防被修改 %r10, %r11 用作数据存储,遵循调用者的使用规则寄存器传输级,简单来说,保存使用前的原始值 %rip:相当于PC指针指向当前指令地址,指向下一条要执行的指令
x86-64 寄存器映射
1.1.2. intel x64 寄存器 RCX、RDX、R8 和 R9 从左到右用于整数和指针参数。 寄存器 RAX、RCX、RDX、R8、R9、R10 和 R11 被认为是易变的,必须在函数调用时被销毁。 RBX、RBP、RDI、RSI、R12、R13、R14、R14 和 R15 必须保存在任何使用它们的函数中。
当参数个数超过寄存器指定的个数时,使用堆栈进行传递。
1.2. 堆栈框架
C语言是面向过程的语言。 它最大的特点是把一个程序分解成若干个过程(函数),例如:入口函数是main,然后调用各个子函数。 在相应的机器语言中,GCC将进程转换成栈帧(frame)。 简单的说,每个栈帧对应一个进程。 在一个典型的栈帧结构中,rbp 指向栈帧的开始,rsp 指向栈顶。
堆栈结构
1.2.1. 函数入口和返回
函数的进入和退出由指令call和ret完成。 举个例子
//test.c
#include
#include
int foo ( int x )
{
int array[] = {1,3,5};
return array[x];
}
/* ----- end of function foo ----- */
int main ( int argc, char *argv[] )
{
int i = 1;
int j = foo(i);
fprintf(stdout, "i=%d,j=%dn", i, j);
return EXIT_SUCCESS;
}
编译通过以下命令获取
gcc -S -o test.s test.c
main:
pushq %rbp #push命令,rsp寄存器的值自动增加
movq %rsp, %rbp
subq $32, %rsp #这句话说明了栈是高地址向地地址分配
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
movl $1, -4(%rbp)
movl -4(%rbp), %eax
movl %eax, %edi #第一个参数传递给edi
call foo
movl %eax, -8(%rbp) # 返回值接收
movq stdout(%rip), %rax
movl -8(%rbp), %ecx
movl -4(%rbp), %edx
movl $.LC0, %esi
movq %rax, %rdi
movl $0, %eax
call fprintf
movl $0, %eax
leave #由于rsp发生了变化,故需要恢复
ret
foo:
pushq %rbp
movq %rsp, %rbp
movl %edi, -20(%rbp)
movl $1, -16(%rbp) # 数组的分配
movl $3, -12(%rbp)
movl $5, -8(%rbp)
movl -20(%rbp), %eax
cltq #将eax的值提升为8字节,即后续可以使用rax,只有x86-64可以使用
movl -16(%rbp,%rax,4), %eax #值保存在rax中。-16+rbp+rax*4---数组索引
popq %rbp #恢复rbp
ret #恢复rip
离开命令等同于:
movl %ebp %esp
popl %ebp
调用指令等同于:
pushq %rip #保存下一条指令(第41行的代码地址)的地址,用于函数返回继续执行
jump foo # 跳转到函数foo
ret 指令等同于:
popq %rip #恢复指令指针寄存器
在汇编中,禁止直接改变rip寄存器的值。 只有 call 和 ret 指令允许间接更改 rip 寄存器的值。
1.2.2. 可选帧指针
刚搞清楚帧指针,你是不是很期待它派上用场,所以你可能会失望,因为大多数程序都添加了优化编译选项:-O2,这几乎是一个普遍的选择。 在这个优化级别,即使是更低的优化级别-O1,帧指针也被去掉了寄存器传输级,即帧指针不再保存在%ebp中,而被用在其他地方。 x86-32时代,当前栈帧总是从保存%ebp开始,空间由运行时决定,通过不断的push和pop改变当前栈帧空间; 从x86-64开始,GCC有了新的选择,优化编译选项——O1,可以让GCC不再使用栈帧指针,即不再使用rbp。
这样,所有的空间都在函数的开头预先分配,不需要栈帧指针; 所有局部变量都可以通过 %rsp 的偏移量访问。 第一个示例使用以下命令编译:
gcc –O1 –S –o test.s test.c
main:
subq $8, %rsp
movl $1, %edi
call foo
movl %eax, %ecx
movl $1, %edx
movl $.LC0, %esi
movq stdout(%rip), %rdi
movl $0, %eax
call fprintf
movl $0, %eax
addq $8, %rsp
ret
foo:
movl $1, -16(%rsp)
movl $3, -12(%rsp)
movl $5, -8(%rsp)
movslq %edi, %rdi
movl -16(%rsp,%rdi,4), %eax
ret
分析main函数,GCC分析发现栈帧只需要8个字节,所以进入main后的第一条指令分配空间
subq $8,%rsp
然后在返回上一个栈帧之前,回收空间
addq $8,%rsp
为什么在主函数中没有引用分配的空间? 这是因为GCC考虑到栈帧的对齐要求,特意做了安排。 再看一下 foo 函数,这里可以看出 %rsp 是如何引用堆栈空间的。 等等,你不需要先预分配空间吗? 为什么这里没有预分配,直接引用栈顶外的地址呢?这就涉及到x86-64引入的牛逼特性——可以访问栈顶
1.2.3. 访问栈顶之外
我们可以通过readelf命令查看可执行程序的头信息
可执行文件头信息
红色区域指出了遵循ABI规则的x86-64版本。 它定义了一些规范。 ABI 之后的具体实现应该满足这些规范。 其中规定程序可以使用栈顶以外的128字节地址。 我们发现GCC利用了这个特点,干脆不给foo函数分配栈帧空间,而是直接使用栈帧外的空间。 这个特性和内联函数的区别在于,这就是编译优化的力量,而不是像内联函数那样直接把函数复制到调用的地方。
下面我们看一下生成程序集的命令
gcc –O2 –S –o test.s test.c
ain:
subq $8, %rsp
movq stdout(%rip), %rdi
movl $3, %ecx
movl $1, %edx
movl $.LC0, %esi
xorl %eax, %eax
call fprintf
xorl %eax, %eax
addq $8, %rsp
ret
直接优化foo函数,结果是3。
1.2.4. 注册保存约定
在过程调用中,调用者的栈帧需要寄存器来暂存数据,被调用者的栈帧也需要寄存器来暂存数据。 如果调用者使用%rbx,被调用者在使用前需要先保存%rbx,然后在返回调用者栈帧之前恢复%rbx。 遵循此使用规则的寄存器是被调用者保存的寄存器,%rbx 对调用者来说是非易失性的。
相反,调用者使用 %r10 来存储局部变量。 为了子函数调用后能够使用%r10,调用者先保存%r10,等子函数返回后再恢复%r10。 遵循此使用规则的寄存器是调用者保存的寄存器。 对于调用者,%r10 是易变的。
#include
#include
void sfact_helper ( long int x, long int * resultp)
{
if (x<=1)
*resultp = 1;
else {
long int nresult;
sfact_helper(x-1,&nresult);
*resultp = x * nresult;
}
}
/* ----- end of function foo ----- */
long int sfact ( long int x )
{
long int result;
sfact_helper(x, &result);
return result;
}
/* ----- end of function sfact ----- */
int main ( int argc, char *argv[] )
{
int sum = sfact(10);
fprintf(stdout, "sum=%dn", sum);
return EXIT_SUCCESS;
}
/* ---------- end of function main ---------- */
main:
subq $8, %rsp
movl $10, %edi
call sfact
movl %eax, %edx
movl $.LC0, %esi
movq stdout(%rip), %rdi
movl $0, %eax
call fprintf
movl $0, %eax
addq $8, %rsp
ret
sfact:
subq $24, %rsp
leaq 8(%rsp), %rsi
call sfact_helper
movq 8(%rsp), %rax
addq $24, %rsp
ret
sfact_helper: # 函数调用开始
pushq %rbp
pushq %rbx
subq $24, %rsp
movq %rdi, %rbx #使用之前保存rbp,rbx
movq %rsi, %rbp
cmpq $1, %rdi
jg .L2
movq $1, (%rsi)
jmp .L1
.L2:
leaq -1(%rdi), %rdi
leaq 8(%rsp), %rsi
call sfact_helper
imulq 8(%rsp), %rbx
movq %rbx, 0(%rbp)
.L1:
addq $24, %rsp
popq %rbx #使用之后恢复rbp,rbx
popq %rbp
ret # 函数返回
在函数 sfact_helper 中,使用了寄存器 %rbx 和 %rbp。 在覆盖之前,GCC选择先保存他们的值。 GCC 在函数返回之前依次恢复它们。
1.2.5. 参数传递
在X86时代,参数传递是通过压栈来实现的。 与CPU相比,内存访问太慢; 因此,函数调用的效率不高。 x86-64时代,寄存器数量多,GCC最多可以使用6个寄存器来存储参数(rdi、rsi、rdx、rcx、r8、r9),6个以上的参数仍然由将它们推入堆栈。 了解这些对我们写代码很有帮助,至少有两个启示:
尽量使用6个以下的参数列表,不要刁难GCC。传递大对象时,尽量使用指针或引用。 由于寄存器只有 64 位,只能存储整数值,因此寄存器不能存储大对象。
#include
#include
int foo ( int arg1, int arg2, int arg3, int arg4, int arg5, int arg6, int arg7 )
{
int array[] = {100,200,300,400,500,600,700};
int sum = array[arg1] + array[arg7];
return sum;
}
/* ----- end of function foo ----- */
int main ( int argc, char *argv[] )
{
int i = 1;
int j = foo(0, 1, 2, 3, 4, 5, 6);
fprintf(stdout, "i=%d,j=%dn", i, j);
return EXIT_SUCCESS;
}
/* ---------- end of function main ---------- */
通过命令 gcc -O1 -S -o test.s test.c 编译为:
main:
subq $24, %rsp
movl $6, (%rsp)
movl $5, %r9d
movl $4, %r8d
movl $3, %ecx
movl $2, %edx
movl $1, %esi
movl $0, %edi
call foo
movl %eax, %ecx
movl $1, %edx
movl $.LC0, %esi
movq stdout(%rip), %rdi
movl $0, %eax
call fprintf
movl $0, %eax
addq $24, %rsp
ret
foo:
movl $100, -32(%rsp)
movl $200, -28(%rsp)
movl $300, -24(%rsp)
movl $400, -20(%rsp)
movl $500, -16(%rsp)
movl $600, -12(%rsp)
movl $700, -8(%rsp)
movslq %edi, %rdi
movslq 8(%rsp), %rdx
movl -32(%rsp,%rdi,4), %eax
addl -32(%rsp,%rdx,4), %eax
ret
main函数在调用foo之前准备好foo的参数,前6个放在寄存器中,第7个放在栈中。
1.2.6. 结构传递参数
#include
#include
struct demo_s {
char var8;
int var32;
long var64;
};
struct demo_s foo (struct demo_s d)
{
d.var8=8;
d.var32=32;
d.var64=64;
return d;
}
/* ----- end of function foo ----- */
int main ( int argc, char *argv[] )
{
struct demo_s d, result;
result = foo (d);
fprintf(stdout, "demo: %d, %d, %ldn", result.var8, result.var32, result.var64);
return EXIT_SUCCESS;
}
/* ---------- end of function main ---------- */
通过命令 gcc -S -o test.s test.c 获取程序集
main:
pushq %rbp
movq %rsp, %rbp
subq $48, %rsp
movl %edi, -36(%rbp)
movq %rsi, -48(%rbp)
movq -16(%rbp), %rdx # 字节对齐结构体后var8和var32共8个字节,都放入到rdx中
movq -8(%rbp), %rax #8个字节的var64 放入到rax
movq %rdx, %rdi
movq %rax, %rsi
call foo
movq %rax, -32(%rbp)
movq %rdx, -24(%rbp)
movq -24(%rbp), %rsi
movl -28(%rbp), %ecx
movzbl -32(%rbp), %eax
movsbl %al, %edx
movq stdout(%rip), %rax
movq %rsi, %r8
movl $.LC0, %esi
movq %rax, %rdi
movl $0, %eax
call fprintf
movl $0, %eax
leave
ret
foo:
pushq %rbp
movq %rsp, %rbp
movq %rdi, %rax
movq %rsi, %rcx
movq %rcx, %rdx
movq %rax, -16(%rbp) #构造结构体出来
movq %rdx, -8(%rbp)
movb $8, -16(%rbp) # 结构体var8第一个字段赋值,
movl $32, -12(%rbp) # 结构体var32第二个字段赋值
movq $64, -8(%rbp) # 结构体var6第三个字段赋值
movq -16(%rbp), %rax #结构体第一个,第二个字段赋值给rax
movq -8(%rbp), %rdx #结构体第三个字段赋值给rdx
popq %rbp
ret
所以我们可以回到关于结构的问题:
问题1:如何传递结构体? 分为两部分,var8和var32合并成8个字节大小,放在寄存器%rdi中,var64放在寄存器的%rsi中。 也就是说,结构被分解了。 问题2:结构是如何存储的? 查看 foo 函数的第 15-17 行,注意对结构的引用变为偏移量访问。 这与数组非常相似,只是它的元素可以是可变大小的。 问题3:如何返回结构体,原来%rax起到返回值的作用,现在增加了返回值2:%rdx。 同样,GCC 使用两个寄存器来表示结构。
即使在默认情况下,GCC 仍会尽力使用寄存器。 随着结构越来越大,寄存器不够用了,只能用栈了。
1.3. 参考
Windows x64 调用约定中使用的 R10-R15 寄存器是什么?
本文使用知乎 VSCode 创建发布