目录
1 函数与栈帧
1.1 概述
1.2 从指令角度理解栈
1.3 递归与栈
1.3.1 示例程序
1.3.2 反汇编分析
1.3.3 栈状态分析
1.3.4 GDB调试验证
1.3.5 栈溢出
2 缓冲区溢出攻击
2.1 缓冲区溢出攻击示例
2.2 缓冲区溢出攻击原理分析
2.2.1 反汇编分析
2.2.2 栈状态分析
2.3 解决方案
2.3.1 参数检查
2.3.2 使用GCC自带的栈保护机制
2.4 GCC -fstack-protector机制验证与分析
2.4.1 -fstack-protector机制反汇编分析
2.4.2 -fstack-protector机制生效实例
2.4.3 -fstack-protector机制生效实例GDB调试验证
2.4.4 -fstack-protector机制未生效实例
2.4.5 -fstack-protector机制未生效实例GDB调试验证
3 从栈切换角度理解进程、线程和协程
3.1 执行单元
3.1.1 概述
3.1.2 进程
3.1.3 线程
3.1.4 协程
3.2 协程调度与切换
3.2.1 协程调度与切换实例
3.2.2 协程创建初始状态
3.2.3 yield_to函数分析
3.2.4 协程切换总结
3.3 进程调度与切换概述
3.3.1 概述
3.3.2 多进程程序示例
3.4 用户态与内核态切换
3.4.1 概述
3.4.2 从用户栈切换到内核栈
3.4.3 从内核栈切换到用户栈
3.5 基于栈实现切换的总结
1. 栈空间在进程虚拟地址空间中的布局,一般被放置在用户空间的地址最高端,下图为IA-32 + Linux和IA-64 + Linux的进程地址空间布局
2. 当调用一个函数时,CPU会在栈空间中开辟一小块区域,用于存储函数的局部变量等内容,这块区域就称作栈帧(stack frame)
3. 栈帧本质上是一个函数的活动记录,当某个函数执行时,会创建栈帧,并将他的活动记录在栈帧中;当该函数执行结束,活动记录会被销毁,也就是撤销栈帧
C语言程序如下,是一个用于说明值传递不会改变参数变量值的示例
程序执行结果如下,
程序反汇编如下,
从函数栈帧的变迁关系可见,之所以值传递不能改变参数变量值,是因为main函数和swap函数的栈帧中各自持有一份参数,对swap函数栈帧中的变量a和变量b进行的任何操作,都不会影响main函数栈帧中的变量a和变量b
关于函数栈帧的详细内容,可参考01. 操作系统工作原理基础 chapter 3
说明1:IA-64体系结构寄存器如下图所示
说明2:关于printf函数格式字符串的传递
在调用printf函数时,需要传递格式字符串。在main函数调用printf函数时,通过edi寄存器传递了立即数0x400649,该立即数就是格式字符串的地址
对照ASCII码表,字符串内容正是"main: a = %d b = %d\n"
说明3:传递指针会怎么样
我们都知道,在C语言中如果想在swap函数中修改main函数中变量a和变量b的值,需要向swap函数传递变量的地址,如下图所示,
从指令的角度,此时向swap函数传递的参数是main函数局部变量的地址,因此可以在swap函数中通过该地址直接修改main函数中的局部变量
其实从传参的角度可以发现,C语言中只有值传递,只是此时传递的是地址值而已
说明4:从上述反汇编结果可见,函数中的局部变量就是函数栈帧上的一段内存空间,并通过rbp寄存器索引
说明5:当函数返回撤销栈帧时,rsp & rbp指针的移动并不会将栈上的数据清空。所以局部变量需要初始化,否则就是随机值
根据上文分析,既然栈帧是函数调用的活动记录,那么递归调用体现在栈上,就是一系列栈帧的堆叠。我们以用递归方式求解阶乘为例,C语言程序如下,
程序执行结果如下,
1. main函数
main函数的实现比较简单,就是使用参数3调用fac函数,并打印返回值
2. fac函数
fac函数会在递归条件满足前,不断递归调用,并且逐层返回
说明:编译优化等级的影响
以-O1优化等级重新编译上述程序,反汇编结果如下,可见不再使用rbp寄存器维护栈底
同时需要注意的是,如下指令会将edi寄存器中的值减1
lea -0x1(%rdi), %edi
我们对函数栈帧的状态进行分析,就更容易理解递归调用与函数栈的关系
1. 每级函数递归都会建立新的栈帧,且每级栈帧的函数参数均不同,逐渐走向递归终止条件
2. 先创建的栈最后才撤销,后创建的栈最先被撤销,这就是先入后出的规律,也是程序执行的活动记录被称作栈的原因
3. 具有先入后出特性的数据结构称作栈,相应地,需要使用先入后出特性解决问题时,可以使用栈这种数据结构
1. 为了能够使用GDB对应用程序进行调试,需要在编译应用程序时增加-g选项,以携带调试信息
2. 以如下命令启动应用程序调试,增加--tui选项可以开启文本用户界面,便于调试
gdb ./fact --tui
3. 将断点设置在fac函数,并使用r(run)命令启动应用程序
之后执行流会停止在断点处,
4. 在各级fac调用处打印参数n的值,可见与之前栈帧状态的分析是一致的
1. 对于上述使用递归求解阶乘的程序,如果不设置递归终止条件,fac函数会出现持续递归调用的情况。这样就会在栈上不断创建栈帧,直至将栈空间耗尽,出现栈溢出(Stack Overflow)
2. 实现上述栈溢出导致段错误(Segment Fault)的原理是操作系统会在栈空间的尾部设置一个禁止读写的页(guard page),一旦栈增长到尾部,操作系统就可以通过异常探知程序在访问栈的末端
本节给出一个通过缓冲区溢出来破坏栈的例子,C语言程序如下,
注意使用如下命令来编译该程序,
gcc -O1 -o stack_attack stack_attack.c -fno-stack-protector
程序运行效果如下,可见程序中虽然没有调用bad函数,但是执行流却进入了该函数
1. main函数在调用test函数时,传参使用的寄存器如下,
2. 在test函数中,首先开辟16B的栈空间,之后调用copy函数时传参使用的寄存器如下,
需要注意的是,由于IA-64中使用满减栈,所以rsp指向局部变量数组s的低地址端,也就是数组s的起始地址
3. 在copy函数中,进行逐字节拷贝
1. 当函数调用进入test函数时,在test函数栈帧中开辟16B空间用于存储局部变量数组s。在与局部变量数组s相邻的高地址处,存储的是main函数跳转到test函数时保存的返回地址
2. 这里由callq指令压栈压栈的test函数返回地址就是攻击的目标,示例代码中在调用test函数时传递的拷贝长度为24,超过了局部变量数组s的范围,并最终将bad函数的地址拷贝到main函数栈中存储test函数返回地址的位置
3. 缓冲区溢出攻击,就是通过一定的手段达到修改不属于本函数栈帧的变量的目的,而这种手段往往是通过向字符串变量或数组中写入错误的值
在示例程序中,就是在test函数中修改了main函数栈帧中保存的返回地址
说明1:缓冲区溢出攻击的要点,
① 栈的越界读写
② 覆盖栈上原有的返回地址
说明2:-O1编译选项的影响
① 对于示例程序,取消-O1编译选项,使用如下命令进行编译,
gcc -o stack_attack stack_attack.c -fno-stack-protector
运行结果如下,
② 在取消-O1编译选项之后,会恢复使用rbp寄存器维护函数调用栈,当越界拷贝数据时,会覆盖栈中保存的旧rbp,而不是覆盖栈中保存的返回地址
因此test函数可以正确返回,但是恢复到ebp寄存器中的值错误,从而导致段错误
1. 对入参的合法性进行检查
例如在示例代码的test函数中,检查传入的参数n是否大于0且小于缓冲区长度
2. 尽量使用strncpy来代替strcpy
1. GCC自带栈保护机制,也就是-fstack-protector选项。当打开该选项时,当其检测到缓冲区溢出时,会立即终止正在执行的程序,并提示其检测到缓冲区存在溢出问题
2. -fstack-protector的实现机制是通过在函数中易受到攻击的目标上下文添加保护变量来完成的,这些函数包括使用了alloc函数以及缓冲区大小超过8B的函数
这些保护变量在进入函数时进行初始化,当函数退出时进行检测,如果某些变量检测失败,那么会打印错误提示信息并终止当前的进程
3. 从GCC 4.8开始,-fstack-protector选项是默认打开的,因此我们在编译示例程序时增加了-fno-stack-protector选项
4. -fstack-protector选项虽然增加了栈的安全性,但是是有性能损耗的(可能中给出一个线上实例的数据,关闭该选项性能可提升8% ~ 10%)
1. 取消-fno-stack-protector选项编译示例程序,根据课程中的说法,取消该选项后,程序执行应该被终止。但在Ubuntu 16.04中实测结果如下,可见缓冲器溢出攻击没有生效,test程序执行完成后,继续返回执行
2. 分析test函数的反汇编结果,可见增加了栈溢出检查。需要注意的时,栈溢出检查时的保护变量存储在rsp + 0x18处,这个位置本身就超过了局部变量数组s的范围
3. 由于test函数中分配的栈空间为40B,而栈保护变量的位置为rsp + 0x18,因此在调用copy函数进行拷贝时,可以拷贝24B,这也就导致之前的缓冲区溢出攻击没有生效
说明:个人觉得-fstack-protector机制扩大预留的栈空间,本身也会起到一定的保护作用
为了验证上述分析,我们定义temp数组并且将调用test函数时传递的拷贝长度从24B增加到32B,此时应该可以覆盖栈保护变量。从验证结果可见,该机制可以检测到栈操作越界
2.4.3.1 调试命令
为了验证-fstack-protector机制,需要在汇编语言层面进行调试(之前是在C语言层面进行调试),因此引入2个调试命令,
1. layout asm
① 在调试界面显示对应的汇编语言窗口
② 使用disassemble命令也可以显示一个地址范围内的反汇编结果,但是没有layout asm命令持续展示方便
2. ni & si
与n & s指令对应,但是在汇编语言层面执行
2.4.3.2 调试过程
1. 创建test函数调用栈
① test函数调用栈通过sub $0x28, %rsp指令实现
② 创建函数调用栈之前,rsp寄存器值如下,
③ 创建函数调用栈之后,rsp寄存器值如下,符合预期
2. test函数参数验证
① 拷贝的源地址为temp数组
② 拷贝的数据长度为32B
3. 保护变量从[%fs:0x28]处获取,其值为0x5e79485d74fa6500
4. 将保护变量的值存储在栈的rsp + 0x18处,存储后的值符合预期
5. 调用copy函数完成拷贝后,存储保护变量值的栈区域被破坏
6. 由于保护变量被破坏,因此调用copy函数之后的栈检查失败,从而跳转到__stack_chk_fail函数执行
从test函数的反汇编代码可见,-fstack-protector选项提供的保护机制并不是完美的。如果缓冲区溢出攻击中,溢出值与栈保护变量值相同,那么-fstack-protector机制就无法检测出该错误
我们在验证中无意发现,使用如下方式调用test函数并不会触发-fstack-protector机制,程序仍看继续执行
说明:需要注意的是,此处作为拷贝源的t数组长度只有24B,并没有32B,后续拷贝的数据是main函数栈上越过t数组的其他数据
1. 创建test函数调用栈
① test函数调用栈通过sub $0x28, %rsp指令实现
② 创建函数调用栈之前,rsp寄存器值如下,
③ 创建函数调用栈之后,rsp寄存器值如下,符合预期
2. test函数参数验证
① 拷贝的源地址为t数组
其中0x00000000004005d6就是bad函数的地址
说明:在main函数栈中,t数组之后的16B为0xf0e7216b32374100,也就是越界覆写到栈中的数据
② 拷贝的数据长度为32B
3. 保护变量从[%fs:0x28]处获取,其值为0xf0e7216b32374100
4. 将保护变量的值存储在栈的rsp + 0x18处,存储后的值符合预期
5. 调用copy函数完成拷贝后,存储保护变量值的栈区并没有被修改
这里很可能是使用虚拟机测试导致的一个巧合
6. 由于栈保护变量没有被破坏,因此程序可以继续运行
操作系统中进程、线程和协程的上下文切换,都是由栈来支撑的,本章就从上下文切换的角度来理解栈
1. 执行单元是指CPU调度和分派的基本单位,是一个CPU能正常运行的基本单元
2. 常见的执行单元有进程(process)、线程(thread)和协程(coroutine)
3. 执行单元可以被挂起和恢复,只要在挂起时将CPU状态(体现在各个寄存器中)全部保存起来;等到恢复该执行单元时,将状态恢复即可
4. 这种保存状态、挂起、恢复执行、状态恢复的完整过程,称为执行单元的调度(Scheduling)
说明1:执行单元要保存的具体CPU状态是与体系结构相关的,不同的体系结构需要保存的寄存器不同
说明2:关于栈的保存
① 栈对执行单元而言是非常重要的数据结构,也是执行单元上下文的重要组成部分,而栈又是由栈顶指针和栈基址指针共同维护的。在IA-64体系结构中,就是由RSP和RBP寄存器共同维护
② 因此,只要保存RSP和RBP寄存器,就可以实现对栈的保存。而RBP寄存器的值也可以保存在栈中,所以一般只要将RBP寄存器的值压栈,然后记录RSP寄存器的值即可
1. 当运行一个可执行程序时,操作系统就会启动一个进程
2. 进程有自己独立的内存空间(进程虚拟地址空间)和页表,以及文件表等各种私有资源
3. 对于多进程系统,如果同时有多个进程并发执行,则会消耗大量的资源
1. 为了解决多进程消耗资源的问题,提出了线程的概念
2. 同一个进程中的线程共享该进程的内存空间和文件表等资源
3. 除了共享的资源,每个线程也有自己的私有空间,这就是线程栈。线程在执行函数调用时,会在自己的线程栈中创建函数栈帧
说明:基于上述特点,通常将进程看作资源分配的单位,将线程看作具体的执行实体
1. 协程是比线程更轻量的执行单元,是一种轻量级的用户态执行单元
2. 进程和线程的调度都是由操作系统负责,而协程是由执行单元之间相互协商进行调度。只有前一个协程主动调用yield函数让出CPU的使用权,下一个协程才能得到调度
3. 协程的切换都是发生在用户态,无需切换至内核态,因此协程的切换和调度所耗费的资源是最少的
说明1:协程协商调度的优点
由于是由程序自己负责协程的调度,而程序本身最清楚自己的运行逻辑,所以在大多数情况下,可以让不那么忙的协程少参与调度,从而提升整个程序的吞吐量
作为对比,进程和线程的调度均由操作系统负责,不会反应出程序的运行逻辑,因此可能调度执行没有繁重任务的进程或线程执行
说明2:从操作系统的演进历史来看,首先出现的是多进程系统,之后出现了多线程系统,最后才是协程被大规模使用。这个演进历程背后的逻辑,就是执行单元越来越轻量,以便支持更大的并发总数
#include
#include
// 协程栈大小
#define STACK_SIZE 1024
// 协程入口点函数类型
typedef void(*coroutine_start)(void);
class coroutine {
public:
// 协程栈顶指针,用于入栈和出栈操作
// 指针基类型为long,在IA-64体系结构中为8B
long *stack_pointer;
// 协程栈内存起始地址,用于分配和释放协程栈
char *stack;
// 构造函数
coroutine(coroutine_start entry)
{
// 如果构造coroutine对象时传递的入口点为NULL,则不分配协程栈
if (entry == NULL) {
stack = NULL;
stack_pointer = NULL;
return;
}
// 如果构造coroutine对象时传递的入口点不为NULL
// 则分配并初始化协程栈
// 分配协程栈
stack = (char *)malloc(STACK_SIZE);
// 计算栈顶指针
char *base = stack + STACK_SIZE;
// 记录栈顶指针
stack_pointer = (long *)base;
// 将协程入口点压栈(满减栈用法)
stack_pointer -= 1;
*stack_pointer = (long)entry;
// 将原始栈顶指针压栈,此处压栈的是EBP(满减栈用法)
stack_pointer -= 1;
*stack_pointer = (long)base;
}
// 析构函数
~coroutine()
{
// 如果分配过协程栈,则将其释放
if (!stack)
return;
free(stack);
stack = NULL;
}
};
coroutine *co_a, *co_b;
// 切成切换函数
// 实现从from协程到to协程的切换
void yield_to(coroutine *from, coroutine *to)
{
// 这段内嵌汇编实现了协程栈的切换
__asm__(
// movq %rsp, from->stack_pointer
"movq %%rsp, %0\n\t"
// movq to->stack_pointer, %rsp
"movq %%rax, %%rsp\n\t"
// 内嵌汇编输出部分
// 0表示from->stack_pointer,存储在内存中
:"=m"(from->stack_pointer)
// 内嵌汇编输入部分
// to->stack_pointer存储在rax寄存器中
:"a"(to->stack_pointer)
);
}
// 协程b入口点
void start_b(void)
{
printf("B");
// 触发协程切换
yield_to(co_b, co_a);
printf("D");
// 触发协程切换
yield_to(co_b, co_a);
}
int main(void)
{
// 构造2个协程对象
co_b = new coroutine(start_b);
// 主线程就是后续的协程a
// 所以无需设置协程入口点
co_a = new coroutine(NULL);
printf("A");
// 触发协程调度
yield_to(co_a, co_b);
printf("C");
// 触发协程调度
yield_to(co_a, co_b);
printf("E\n");
delete co_a;
delete co_b;
return 0;
}
使用如下方式进行编译,
g++ -O0 -g -o coroutine coroutine.cpp
运行效果如下图所示,可见协程a和协程b可以协同工作,而且切换是由协程主动触发的
说明1:使用-O0编译选项
此处需要使用-O0进行编译,不能使用更高的优化级别。这是因为更高级别的优化会将yield_to函数内联,从而导致栈的布局和程序中的期待不一致
说明2:主线程作为协程a运行
① 程序中在创建协程a时,没有设置该协程的入口点,也就不会为该协程分配协程栈。这是因为我们就是将主线程作为协程a,因为协程a相当于已经在运行,所以无需设置入口点;而主线程的线程栈就作为协程a的协程栈使用
② 可见协程b的协程栈是在主线程所在进程的堆中分配的
1. 在构造协程b时,首先为其分配了1KB的协程栈,之后对该栈进行初始化,初始化之后的协程栈如下图所示,
2. 协程栈的构造方式与协程的切换方式相关,详见下文分析
3.2.3.1 yield_to函数调用分析
首先分析一下main函数对yield_to函数的调用,由于该程序使用C++实现,g++编译器会在函数名中添加内容
3.2.3.2 yield_to函数反汇编分析
协程切换的关键就是yield_to函数,该函数反汇编如下,
yield_to函数的要点如下,
1. 首先进行协程栈的切换,之后通过retq指令就实现了执行流的切换
2. 进行协程切换时,以从协程a切换到协程b为例,2个协程栈的状态如下,
由于当时协程b也是通过调用yield函数进行切换的,所以当从协程a切换到协程b时,协程b栈顶部分的内容和协程a是非常相似的(今天的协程a就是昨天的协程b)
3. 根据协程切换时的栈状态,就可以知道应该如何构建协程栈的初始状态。因为构造协程栈的初始状态,就是构造第一次切换到该协程时的栈状态
可见只需要将上图中的yield函数返回地址替换为协程入口地址,然后压入适当的EBP值即可,这与示例程序中的实现是一致的
说明1:通过上述示例可见,通过协程,我们在同一个线程中实现了两个执行单元。这点非常类似通过线程,在同一个进程中实现多个执行单元
说明2:编译优化等级的影响
① 根据之前的经验,当使用-O1优化等级编译程序时,编译后生成代码在维护栈时,将不再使用rbp寄存器,那么我们构造的协程初始状态就需要进行修改
我们首先使用-O1优化等级编译示例程序,可见程序已无法运行
② 接着查看yield_to函数编译后的变化,可见函数中仅包含栈切换与retq操作
③ 根据yield_to函数的变化,我们修改构造的协程初始状态,不再压栈rbp寄存器值,则程序可以正确运行
1. 每个协程都有自己的寄存器上下文和栈,在协程切换时,将寄存器上下文和栈保存起来;在协程切换回来时,恢复先前保存的寄存器上下文和栈
2. 协程的切换和调度都发生在用户态,并没有使用任何操作系统的系统调用
3. 协程的调度是协商式的,而不是抢占式的。协程的切换完全靠一个协程主动调用yield_to函数将执行权让渡给其他协程
说明1:寄存器上下文的保存
可以将寄存器上下文先保存在栈中,之后再保存栈;也可以将寄存器上下文保存在类似coroutine的结构中,就和示例程序中保存rsp的方式类似(类似TCB的结构中)
说明2:示例程序中其实没有保存完整的寄存器上下文,只是保存了ESP寄存器
说明3:协作式多任务与抢占式多任务
① 目前主流语言基本上以多线程作为并发设施,与线程相关的概念是抢占式多任务(Preemptive multitasking),而与协程相关的是协作式多任务
② 不管是进程还是线程,每次阻塞与切换都需要陷入内核态,由操作系统的调度程序决定继续运行哪个进程或线程。由于抢占式调度的执行顺序无法确定,所以在线程中需要非常小心的处理同步与互斥问题
③ 由于协程是协作式调度,由用户自己负责调度的时机,所以处理得当的话,协作式调度就没有同步与互斥问题(可以认为协作式调度本身就是一种同步)
④ 当然,如果一个协程不主动让出,其他协程就没有办法得到调度执行,这是协程的一个弱点
进程切换的原理与协程切换其实大致相同,都是将上下文保存在特定位置,然后切换到新的进程去执行。主要有如下2点不同,
1. 进程持有大量资源,所以在进程切换过程中,除了要切换寄存器上下文和执行流(通过栈切换),还要切换资源(e.g. 切换进程虚拟地址空间)
2. 操作系统为用户提供了进程的创建、销毁、信号通信等基础设置
3.3.2.1 示例代码
使用如下命令编译该程序,
gcc -o fork fork.c
程序运行效果如下,
3.3.2.2 父子进程调度
1. 由于进程的调度执行由操作系统负责,具有很大的随机性,所以父进程和子进程谁先被调度执行以及谁先退出是不确定的
2. 为了避免让子进程成为孤儿进程,示例程序中让父进程等待子进程退出,从而实现两个进程的同步
3.3.2.3 进程栈的写时复制
1. 创建进程时,子进程继承了父进程的所有数据,包括栈上的数据
2. 只要有一个进程对栈进行修改,栈就会复制一份,然后父子进程各自持有一份,如下图所示,
3. 对于其他共享区域,如果进程进行修改(如果可以修改的话),也会复制一份副本,这就是写时复制机制
说明1:以main函数中的pid局部变量为例,他在父进程和子进程的mian函数栈帧中均有一个副本
说明2:进程栈(更准确的说是进程的用户栈)是通过写时复制机制创建的,而协程和线程的栈是提前分配的
1. 当进程陷入内核态执行时,也需要使用栈,这个栈称为内核栈。他与进程的用户栈不同,只有高权限的代码才能访问
2. 正是因为对内核栈的访问保护要求,因此用户态与内核态的切换会导致栈的切换,本质上是特权等级(异常等级)的切换会导致栈的切换,这样就可以确保所使用的栈与当前的特权等级一致
3. 用户态与内核态的栈切换操作是体系结构相关的,例如X86体系结构通过TSS段实现,AArch64体系结构通过SP_ELx寄存器实现
后续说明以X86体系结构为例
1. 假设进程当前运行在用户态(ring 0),当进程调用系统调用或者发生中断时,CPU根据设置跳转到目标特权级,并且从TSS段中取得与目标特权级相对应的段选择子和栈指针,分别加载到ss和rsp寄存器,这就完成了从用户栈到内核栈的切换。相关内容可参考X86汇编语言从实模式到保护模式16:特权级和特权级保护chapter 4
2. 在特权级切换的过程中,CPU硬件会将原先的ss / rsp / cs / rip寄存器保存在切换后的内核栈中
3. 其他的寄存器上下文需要中断服务程序做进一步的压栈,以便后续返回时恢复CPU状态,所以中断处理是一个软硬件结合的过程
说明:CPU自动将用户栈的段选择子和栈指针保存在内核栈中,就实现了用户栈和内核栈的关联。这样当内核栈发生切换时(也就是进程或线程发生切换),用户栈也就跟着进行了切换
1. 首先需要说明的是,执行流不能直接从高特权级通过跳转指令直接跳转到低特权级,只能通过iret指令返回用户态。可参考X86汇编语言从实模式到保护模式16:特权级和特权级保护 chapter 2
2. 当中断结束时(包括系统调用),最后需要执行iret指令。该指令会将用户栈的选择子和栈指针出栈,分别加载到ss和rsp寄存器,这样就完成了从内核栈到用户栈的切换
与此同时,也会将内核栈中的cs和rip出栈,实现执行流的返回
1. 栈切换的核心是rsp寄存器的切换,只要切换了rsp就相当于切换了执行单元的上下文环境,因为执行单元的上下文环境就保存在栈上
2. 栈和执行单元往往是一对一的关系,栈的活跃就代表他所对应的执行单元活跃。栈上的数据非常敏感,一旦被攻击,往往会造成巨大的破坏
3. 本章之前的说明,是利用栈来实现函数跳转,其实也是一种执行流的切换,只不过是在同一个栈中,或者说在同一个执行单元中
而进程、线程和协程的上下文切换,其中的执行流切换还是依靠栈来实现的,只不过涉及两个不同执行单元的栈。因此在执行单元切换时,就包括了栈切换、执行流切换、资源切换这3个方面
其中资源切换主要与进程切换相关
4. 特权等级的切换会导致栈的切换,也就是用户栈与内核栈之间的切换,只不过这2个栈属于同一个执行单元
这里主要与进程和线程相关,协程没有单独的内核栈
说明:关于线程的内核栈
在Linux中,没有很好地支持线程,而是直接利用了进程的结构来表示线程(只是进程中的多个线程共享用户地址空间),因此是为每个线程分配了一个内核栈
但是Linux的多线程支持还在不断完善中