03-上下文切换

来源:互联网 发布:c语言入门书籍下载 编辑:程序博客网 时间:2024/05/17 08:01

在你没搞懂汇编级的函数调用流程以及上一篇的控制流切换原理前,阅读本文可能会相当吃力。不过你可以将这几篇文章同时打开,互相对照可能会加深理解。

在上一篇中已经用 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_toswitch_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 函数不能返回?

0 0