03-上下文切换
2017-03-05 20:02
399 查看
在你没搞懂汇编级的函数调用流程以及上一篇的控制流切换原理前,阅读本文可能会相当吃力。不过你可以将这几篇文章同时打开,互相对照可能会加深理解。
在上一篇中已经用 C 语言和汇编分别完成了两个小实验,告诉你如何通过更改栈来达到控制流转向你所期望的目的地。不过,这只是切换出去,要完成线程调度,最关键的一点在于还得切换回来。
上面那段话是说,当我们什么时候想切换回来的时候,只要更改一下栈(这个你已经学会了)同时在恢复寄存器环境,我们就好像从以前切出去的那个位置继续执行了。
这样的切换,我们称之为上下文切换。所谓的上下文,指是就是当前的寄存器环境(eax, edx, ecx, ebx, esp, ebp, esi, edi, eflags)。
比方说有这样的结构体:
三个线程对应的结构体是
图1 上下文切换
上面的过程用汇编很容易实现,不过在实际的实现版本中,没有采用这种方法,而是使用了更加简洁的方法——将当前寄存器的环境保存在当前所使用的栈中。具体过程见图 2.
图2 基于栈的上下文切换
图 2 中的步骤可以叙述为下:
线程 0 (请允许我称此为线程吧)正准备切换时,将当前 cpu 中的寄存器环境一个一个压入到自己的栈中,最后一个压栈的是 eflags 寄存器。
线程 0 将自己的栈顶指针(保存 eflags 的那个位置)保存到全局数组 task[0] 中。
线程 0 从全局数据 task 中取出下一个线程的栈顶,假设下一个要运行的线程是 1 号线程,则从 task[1] 中取出线程 1 的栈顶指针保存到 cpu 的 esp 寄存器中。此时意味着栈已经被切换。栈切换完成后,本质上已经在线程 1 中了。
线程 1 将自己栈中的寄存器环境 pop 到对应的 cpu 寄存器中,比如第一个 pop 到 eflags 中,最后一个是 pop ebp.
按照上述步骤,线程完成上下文切换。
switch.s
运行结果如图 3.
图3 上下文切换实例运行结果
特别注意的是,为了方便管理所有的线程回调函数 fun1 和 fun2,这里借助了一个 start 函数来统一管理它们,这样一来,我们每次构造环境的代码就可以统一起来。窍门在于 main 函数中的初始环境的构造。我们以 fun1 为例。
图4 switch_to 函数
图5 构造线程 1 的运行栈的样子
当 main 函数执行到 switch_to(1) 的时候,注意进入 switch_to 里面时,switch_to 的前半段(图 4 中第 6 行到 第 22 行),使用的栈都还是主线程的栈,第 6 行到第 16 行将当前寄存器环境保存了主线程的栈中,如图 5 中右侧的栈。
执行图 4 中的第 23 行时,正是栈的切换操作,这一行执行完成后,栈就变成了图 5 中左侧的栈。接下来的 26 开始,就已经算是进入了另一个线程了。
很奇妙吧,一个 switch_to 函数竟然同时跨越了 2 个线程,其本质就是栈变了。
从 26 行开始,一连串的 pop 动作将栈中的值弹到 cpu 寄存器中。我们在构造的时候,只是随便填了一些值,因为这并不会有任何影响,你继续跟踪代码就知道了。switch_to 执行到 ret 指令的时候,esp 这个时候指向的是 stack1[1021] 这个位置,一旦 ret,就进入了 start 函数,这个技巧在上一篇文章你早已学会。
进入 start 函数后,栈的样子如图 6.
图6 线程 1 的运行栈
此时代码处于刚进入 start 函数的状态,栈顶在 stack1[1022] 的位置。根据函数栈帧分析,stack1[1023] 的位置是 start 函数的参数,它的参数值是 1。
而 stack1[1022] 中保存的那个 100,是将来 start 函数执行 ret 指令时,要返回的那个地址。可是我们不能让 start 返回,因为地址 100 那个位置并不知道有什么代码,所以,坚决不能让 start 函数返回!!!
上面的过程给人的感觉好像就是“有谁”调用了
经过细致的分析,只要进入了 start 函数,我相信那一段 c 语言代码对你来说是无比简单。
理解本文中的实验代码
理解栈帧,以及函数如何寻参
练习 1:分析 fun1 函数是如何通过 switch_to 进入到 fun2 函数的。
练习 2:分析 fun2 函数下一次如何切换回 fun1。
思考:为什么 start 函数不能返回?
在上一篇中已经用 C 语言和汇编分别完成了两个小实验,告诉你如何通过更改栈来达到控制流转向你所期望的目的地。不过,这只是切换出去,要完成线程调度,最关键的一点在于还得切换回来。
1. 上下文切换
上下文切换不同于上一篇所述的暴力切换,因为上一篇文章的实验里,我们永远无法在返回到 main 函数中。如果你想从那个 fun 函数再跳回目的地,我们需要在切换控制流前保存当前寄存器环境,以及当前的栈顶位置。上面那段话是说,当我们什么时候想切换回来的时候,只要更改一下栈(这个你已经学会了)同时在恢复寄存器环境,我们就好像从以前切出去的那个位置继续执行了。
这样的切换,我们称之为上下文切换。所谓的上下文,指是就是当前的寄存器环境(eax, edx, ecx, ebx, esp, ebp, esi, edi, eflags)。
2. 保存寄存器环境
我们有很多种手段保存寄存器环境。最简单的一种就是保存到定义好结构体去。假设我们有 3 个线程,那就需要 3 结构体变量,分别保存自己的寄存器环境。比方说有这样的结构体:
struct context { int eax; int edx; int ecx; int ebx; int esp; int ebp; int esi; int edi; int eflags; }
三个线程对应的结构体是
struct context ctx[3]. 当我们从线程 0 切换到线程 1 的时候,我们就将线程 0 当前的寄存器环境保存到 ctx[0] 里去。什么时候我们重新切换回线程 0 的时候,再把 ctx[0] 中的值恢复到所有寄存器中。
图1 上下文切换
上面的过程用汇编很容易实现,不过在实际的实现版本中,没有采用这种方法,而是使用了更加简洁的方法——将当前寄存器的环境保存在当前所使用的栈中。具体过程见图 2.
图2 基于栈的上下文切换
图 2 中的步骤可以叙述为下:
线程 0 (请允许我称此为线程吧)正准备切换时,将当前 cpu 中的寄存器环境一个一个压入到自己的栈中,最后一个压栈的是 eflags 寄存器。
线程 0 将自己的栈顶指针(保存 eflags 的那个位置)保存到全局数组 task[0] 中。
线程 0 从全局数据 task 中取出下一个线程的栈顶,假设下一个要运行的线程是 1 号线程,则从 task[1] 中取出线程 1 的栈顶指针保存到 cpu 的 esp 寄存器中。此时意味着栈已经被切换。栈切换完成后,本质上已经在线程 1 中了。
线程 1 将自己栈中的寄存器环境 pop 到对应的 cpu 寄存器中,比如第一个 pop 到 eflags 中,最后一个是 pop ebp.
按照上述步骤,线程完成上下文切换。
3. 上下文切换实验
3.1 程序清单
main.c 主程序// main.c #include <stdio.h> int task[3] = {0, 0, 0}; int cur = 0; void switch_to(int n); void fun1() { while(1) { printf("hello, I'm fun1\n"); sleep(1); // 强制切换到线程 2 switch_to(2); } } void fun2() { while(1) { printf("hello, I'm fun2\n"); sleep(1); // 强制切换到线程 1 switch_to(1); } } // 线程启动函数 void start(int n) { if (n == 1) fun1(); else if(n == 2) fun2(); } int main() { int stack1[1024] = {0}; int stack2[1024] = {0}; task[1] = (int)(stack1+1013); task[2] = (int)(stack2+1013); // 创建 fun1 线程 // 初始 switch_to 函数栈帧 stack1[1013] = 7; // eflags stack1[1014] = 6; // eax stack1[1015] = 5; // edx stack1[1016] = 4; // ecx stack1[1017] = 3; // ebx stack1[1018] = 2; // esi stack1[1019] = 1; // edi stack1[1020] = 0; // old ebp stack1[1021] = (int)start; // ret to start // start 函数栈帧,刚进入 start 函数的样子 stack1[1022] = 100;// ret to unknown,如果 start 执行结束,表明线程结束 stack1[1023] = 1; // start 的参数 // 创建 fun2 线程 // 初始 switch_to 函数栈帧 stack2[1013] = 7; // eflags stack2[1014] = 6; // eax stack2[1015] = 5; // edx stack2[1016] = 4; // ecx stack2[1017] = 3; // ebx stack2[1018] = 2; // esi stack2[1019] = 1; // edi stack2[1020] = 0; // old ebp stack2[1021] = (int)start; // ret to start // start 函数栈帧,刚进入 start 函数的样子 stack2[1022] = 100;// ret to unknown,如果 start 执行结束,表明线程结束 stack2[1023] = 2; // start 的参数 switch_to(1); }
switch.s
/*void switch_to(int n)*/ .section .text .global switch_to // 导出函数 switch_to switch_to: push %ebp mov %esp, %ebp /* 更改栈帧,以便寻参 */ /* 保存现场 */ push %edi push %esi push %ebx push %edx push %ecx push %eax pushfl /* 准备切换栈 */ mov cur, %eax /* 保存当前 esp */ mov %esp, task(,%eax,4) mov 8(%ebp), %eax /* 取下一个线程 id */ mov %eax, cur /* 将 cur 重置为下一个线程 id */ mov task(,%eax,4), %esp /* 切换到下一个线程的栈 */ /* 恢复现场, 到这里,已经进入另一个线程环境了,本质是 esp 改变 */ popfl popl %eax popl %edx popl %ecx popl %ebx popl %esi popl %edi popl %ebp ret
3.2 编译和运行
$ gcc main.c switch.s -o main $ ./main
运行结果如图 3.
图3 上下文切换实例运行结果
3.3 程序分析
本文实验的难点在于第一次切换到另一个线程时,那个线程的上下文并不存在。所以在 main 函数中,我们要事先构造出要被切换的那些线程的上下文。特别注意的是,为了方便管理所有的线程回调函数 fun1 和 fun2,这里借助了一个 start 函数来统一管理它们,这样一来,我们每次构造环境的代码就可以统一起来。窍门在于 main 函数中的初始环境的构造。我们以 fun1 为例。
// 创建 fun1 线程 // 初始 switch_to 函数栈帧 stack1[1013] = 7; // eflags stack1[1014] = 6; // eax stack1[1015] = 5; // edx stack1[1016] = 4; // ecx stack1[1017] = 3; // ebx stack1[1018] = 2; // esi stack1[1019] = 1; // edi stack1[1020] = 0; // old ebp stack1[1021] = (int)start; // ret to start // start 函数栈帧,刚进入 start 函数的样子 stack1[1022] = 100;// ret to unknown,如果 start 执行结束,表明线程结束 stack1[1023] = 1; // start 的参数
图4 switch_to 函数
图5 构造线程 1 的运行栈的样子
当 main 函数执行到 switch_to(1) 的时候,注意进入 switch_to 里面时,switch_to 的前半段(图 4 中第 6 行到 第 22 行),使用的栈都还是主线程的栈,第 6 行到第 16 行将当前寄存器环境保存了主线程的栈中,如图 5 中右侧的栈。
执行图 4 中的第 23 行时,正是栈的切换操作,这一行执行完成后,栈就变成了图 5 中左侧的栈。接下来的 26 开始,就已经算是进入了另一个线程了。
很奇妙吧,一个 switch_to 函数竟然同时跨越了 2 个线程,其本质就是栈变了。
从 26 行开始,一连串的 pop 动作将栈中的值弹到 cpu 寄存器中。我们在构造的时候,只是随便填了一些值,因为这并不会有任何影响,你继续跟踪代码就知道了。switch_to 执行到 ret 指令的时候,esp 这个时候指向的是 stack1[1021] 这个位置,一旦 ret,就进入了 start 函数,这个技巧在上一篇文章你早已学会。
进入 start 函数后,栈的样子如图 6.
图6 线程 1 的运行栈
此时代码处于刚进入 start 函数的状态,栈顶在 stack1[1022] 的位置。根据函数栈帧分析,stack1[1023] 的位置是 start 函数的参数,它的参数值是 1。
而 stack1[1022] 中保存的那个 100,是将来 start 函数执行 ret 指令时,要返回的那个地址。可是我们不能让 start 返回,因为地址 100 那个位置并不知道有什么代码,所以,坚决不能让 start 函数返回!!!
上面的过程给人的感觉好像就是“有谁”调用了
start(1)一样,可实际上是并没有任何人调用它!所以 start 函数就好像是世界的起点,同时这个函数永远没有终点,世界的尽头到底是什么?
经过细致的分析,只要进入了 start 函数,我相信那一段 c 语言代码对你来说是无比简单。
4. 总结
掌握上下文是如何切换的理解本文中的实验代码
理解栈帧,以及函数如何寻参
练习 1:分析 fun1 函数是如何通过 switch_to 进入到 fun2 函数的。
练习 2:分析 fun2 函数下一次如何切换回 fun1。
思考:为什么 start 函数不能返回?
相关文章推荐
- yii2学习笔记——03php5.4和php5.3的测试
- Shell传递参数~03
- 三消类游戏《万圣大作战》03:触摸事件与精灵的交换
- 03 最长不重复子串Longest Substring Without Repeating Characters
- 03 hibernate入门案例的代码优化(视频笔记)
- [asp.net mvc 奇淫巧技] 03 - 枚举特性扩展解决枚举命名问题和支持HtmlHelper
- 用Quick-Cocos2d-x 3.3简单开发微信打飞机 -03 添加爆炸动画和子弹与敌机的碰撞
- 【CRM项目03】专业和岗位管理
- maven学习笔记-03-maven安装篇
- 03. Servlet 的转发和包含
- no-jquery 03 Ajax
- ThinkPHP5 批量注册路由 - 03
- 03-树2. Tree Traversals Again (25)
- 20170729Python03_字符串
- 昨天,用SESSION在4。03下工作
- 03分布式内存NOSQL_redis List
- Java多线程基础--03之 Thread中start()和run()的区别
- GARFIELD@01-03-2005
- 03 通配符选择器 选择器深入讨论(父子选择器、多选择器并存问题、优先级)
- Java学习笔记整理03