上下文切换

前言

OSTEP中将操作系统的核心功能总结为三个抽象:虚拟CPU虚拟内存文件。 其中,虚拟CPU 的实现正是依赖于上下文切换机制,它让单个物理CPU看起来像多个虚拟CPU,同时运行多个程序。

基础概念

1.进程

进程是运行中的程序,程序本身只是存在于磁盘上的静态指令和数据,而操作系统让这些字节 "活" 起来,成为进程, 进程的本质是操作系统为程序提供的一种抽象,让程序看起来拥有自己的 CPU、自己的内存空间和自己的 I/O 设备。而上下文切换正是实现这种抽象的核心机制

2.执行流

执行流是程序在 CPU 上的执行过程:

  1. CPU 寄存器的状态变化:随着程序执行,寄存器中的值不断变化,记录着程序的执行状态

  2. 栈的动态变化:函数调用时压栈,函数返回时出栈,栈记录着程序的调用历史和局部变量

简单来说,执行流就是程序 "活着" 的过程,就像一个人在完成任务的过程中,大脑中的想法和手上的工作不断变化。

3.上下文切换

上下文是执行流在某个特定时刻的瞬时快照,它包含:

  • CPU 寄存器的当前值:包括程序计数器(PC)、栈指针(SP)、通用寄存器等

  • 栈的当前状态:栈指针指向的位置,以及栈中的内容

  • 进程的状态信息:包括进程控制块(PCB)中的信息,如进程状态、优先级、内存映射等

上下文就像你在写文档时的 "快照"的锚点(记录了光标位置、当前页面内容、打开的文件列表等),有了这个快照,你就可以随时暂停工作,之后再从这个点继续。

上下文切换是操作系统将CPU控制权从一个执行流切换到另一个执行流

  1. 保存当前执行流(old)的上下文:将当前CPU寄存器的值、栈状态等保存到内存中

  2. 选择下一个要执行的执行流:操作系统调度器根据调度算法选择下一个要运行的进程或线程

  3. 恢复新执行流(new)的上下文:将新执行流的上下文从内存中加载到CPU寄存器中,让CPU 从上次暂停的地方继续执行

一个简易的上下文切换示例,在这个示例中,通过汇编指令实现执行流切换,将当前(main)线程的callee保存到old,然后从new中读取(task)新线程数据到cpu实现上下文切换。

rust 复制代码
#[unsafe(naked)]
pub unsafe extern "C" fn switch_context(old: &mut TaskContext, new: & TaskContext) {
    naked_asm! (
        // 这里a0和a1分别是old和new的指针
        // 1. 将当前上下文数据和寄存器值/状态,写入a0寄存器
        "sd     sp, 0(a0)       ", // 保存 sp 到 old.sp (偏移 0)
        "sd     ra, 8(a0)       ", // 保存 ra 到 old.ra (偏移 8)
        "sd     s0, 16(a0)      ", // 保存 s0 到 old.s0 (偏移 16)
        "sd     s1, 24(a0)      ",
        "sd     s2, 32(a0)      ", 
        "sd     s3, 40(a0)      ", 
        "sd     s4, 48(a0)      ", 
        "sd     s5, 56(a0)      ", 
        "sd     s6, 64(a0)      ", 
        "sd     s7, 72(a0)      ", 
        "sd     s8, 80(a0)      ", 
        "sd     s9, 88(a0)      ", 
        "sd     s10, 96(a0)     ", 
        "sd     s11, 104(a0)    ",  

        // 2. 将a1寄存器数据恢复到CPU
        "ld     sp, 0(a1)       ", 
        "ld     ra, 8(a1)       ", 
        "ld     s0, 16(a1)      ", 
        "ld     s1, 24(a1)      ", 
        "ld     s2, 32(a1)      ", 
        "ld     s3, 40(a1)      ", 
        "ld     s4, 48(a1)      ", 
        "ld     s5, 56(a1)      ", 
        "ld     s6, 64(a1)      ", 
        "ld     s7, 72(a1)      ",
        "ld     s9, 88(a1)      ", 
        "ld     s10, 96(a1)     ", 
        "ld     s11, 104(a1)    ", 
        "li a0, 0", // 清空两个寄存器数据防止污染其他调用
        "li a1, 0",
        "ret"
    )
}


static COUNTER: AtomicU32 = AtomicU32::new(0);

#[test]
fn test_switch_to_task() {
        COUNTER.store(0, Ordering::SeqCst);

        static mut MAIN_CTX_PTR: *mut TaskContext = std::ptr::null_mut();
        static mut TASK_CTX_PTR: *mut TaskContext = std::ptr::null_mut();

        // 需要执行的任务
        extern "C" fn cooperative_task() {
            COUNTER.store(99, Ordering::SeqCst);
            unsafe {
                switch_context(&mut *TASK_CTX_PTR, &*MAIN_CTX_PTR);
            }
        }

        let (_stack_buf, stack_top) = alloc_stack();
        let mut main_ctx = TaskContext::empty();
        let mut task_ctx = TaskContext::empty();
        task_ctx.init(stack_top, cooperative_task as *const () as usize);

        // 切换上下文(将主线程切换到任务线程执行任务)
        unsafe {
            MAIN_CTX_PTR = &mut main_ctx;
            TASK_CTX_PTR = &mut task_ctx;
            switch_context(&mut main_ctx, &task_ctx);
        }

        assert_eq!(COUNTER.load(Ordering::SeqCst), 99);
}

更进一步的话,还可以模拟线程调度的情况,上面的切换是我实行的一个手动切换,但是在操作系统中是由调度器按照调度算法去分配CPU的,这时候所有任务是否执行切换都靠调度器来进行切换,而每个任务都有其状态,判断任务是否准备可用。

rust 复制代码
#![cfg(target_arch = "riscv64")]

use core::arch::naked_asm;
use std::{print, println};

/// Per-thread stack size. Slightly larger to avoid overflow under QEMU / test harness.
const STACK_SIZE: usize = 1024 * 128;

/// Task context (riscv64); layout must match `01_stack_coroutine::TaskContext` and the asm below.
#[repr(C)]
#[derive(Debug, Default, Clone)]
pub struct TaskContext {
    sp: u64,
    ra: u64,
    s0: u64,
    s1: u64,
    s2: u64,
    s3: u64,
    s4: u64,
    s5: u64,
    s6: u64,
    s7: u64,
    s8: u64,
    s9: u64,
    s10: u64,
    s11: u64,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ThreadState {
    Ready, // 准备就绪
    Running, // 运行中
    Finished, // 已完成
}

struct GreenThread {
    ctx: TaskContext,
    state: ThreadState,
    _stack: Option<Vec<u8>>,
    /// 用户函数入口,首次调度线程时传给 thread_wrapper
    entry: Option<extern "C" fn()>,
}

/// 当前函数入口,切换到新的线程之前由调度程序设置,thread_wrapper 会读取并调用该设定一次。
static mut CURRENT_THREAD_ENTRY: Option<extern "C" fn()> = None;

/// 全局线程调度器
static mut SCHEDULER: *mut Scheduler = std::ptr::null_mut();

/// 每个线程的初始化 ra 操作由 Wrapper 来执行:首先调用函数入口,然后标记为已完成,再切换回原状态。
extern "C" fn thread_wrapper() {
    let entry = unsafe { core::ptr::read(&raw const CURRENT_THREAD_ENTRY) };
    if let Some(f) = entry {
        unsafe { CURRENT_THREAD_ENTRY = None };
        f();
    }
    thread_finished();
}

/// Save current callee-saved regs into `old`, load from `new`, then `ret` to `new.ra`.
/// Zero `a0`/`a1` before `ret` so we don't leak pointers into the new context.
///
/// Must be `#[unsafe(naked)]` to prevent the compiler from generating a prologue/epilogue.
#[unsafe(naked)]
unsafe extern "C" fn switch_context(_old: &mut TaskContext, _new: &TaskContext) {
    naked_asm!(
        "sd sp, 0(a0)",
        "sd ra, 8(a0)",
        "sd s0, 16(a0)",
        "sd s1, 24(a0)",
        "sd s2, 32(a0)",
        "sd s3, 40(a0)",
        "sd s4, 48(a0)",
        "sd s5, 56(a0)",
        "sd s6, 64(a0)",
        "sd s7, 72(a0)",
        "sd s8, 80(a0)",
        "sd s9, 88(a0)",
        "sd s10, 96(a0)",
        "sd s11, 104(a0)",
        
        "ld sp, 0(a1)",
        "ld ra, 8(a1)",
        "ld s0, 16(a1)",
        "ld s1, 24(a1)",
        "ld s2, 32(a1)",
        "ld s3, 40(a1)",
        "ld s4, 48(a1)",
        "ld s5, 56(a1)",
        "ld s6, 64(a1)",
        "ld s7, 72(a1)",
        "ld s8, 80(a1)",
        "ld s9, 88(a1)",
        "ld s10, 96(a1)",
        "ld s11, 104(a1)",
        "li a0, 0", // 清空两个寄存器数据防止污染其他调用
        "li a1, 0",
        "ret",
    );
}

pub struct Scheduler {
    // 线程池
    threads: Vec<GreenThread>,
    // 当前执行线程下标
    current: usize,
}

impl Scheduler {
    pub fn new() -> Self {
        let main_thread = GreenThread {
            // 上下文
            ctx: TaskContext::default(),
            // 调度状态
            state: ThreadState::Running,
            // 栈
            _stack: None,
            // 函数指针
            entry: None,
        };

        Self {
            threads: vec![main_thread],
            current: 0,
        }
    }

    /// 创建一个新的线程
    pub fn spawn(&mut self, entry: extern "C" fn()) {
        // 分配栈内存
        let stack = vec![0u8; STACK_SIZE];
        let top_ptr = (stack.as_ptr() as usize + STACK_SIZE - 16) & !15;

        // 初始化上下文关键参数
        let mut new_ctx = TaskContext::default();
        new_ctx.ra = (thread_wrapper as *const ()) as u64; //extern "C" fn的宽度就是u64位的,所以ret执行可以跳转到对应的函数头
        new_ctx.sp = top_ptr as u64;

        // 创建一个线程对象
        let new_thread = GreenThread {
            // 上下文
            ctx: new_ctx,
            // 调度状态
            state: ThreadState::Ready,
            // 栈
            _stack: Some(stack),
            // 函数入口
            entry: Some(entry),
        };

        self.threads.push(new_thread);
    }

    /// 开始执行调度器
    pub fn run(&mut self) {
        unsafe {
            // 切换调度器
            SCHEDULER = self as *mut Scheduler;
            println!("Get Scheduler perfect!!!");
            // 循环执行线程,直到所有线程都执行完毕
            while !self.threads[1..]
                .iter()
                .all(|t| t.state == ThreadState::Finished)
            {
                // 执行下次调度
                self.schedule_next();
            }

            SCHEDULER = std::ptr::null_mut();
        }
    }

    /// 调度下一个就绪状态的线程
    fn schedule_next(&mut self) {
        let current_idx = self.current;
        let threads = &mut self.threads;
        let length = threads.len();

        
        // 从当前线程向后闭环寻找Ready状态线程
        let mut next_idx = None;
        for i in 1..length {
            let index = (current_idx + i) % length;
            let thread = &threads[index];
            if ThreadState::Ready == thread.state {
                next_idx = Some(index);
                break;
            }
        }

        if let Some(next_idx) = next_idx {

            println!("cur_idx:{},next_idx:{}",current_idx,next_idx);
            let (curr_thread, next_thread) = if next_idx > current_idx {
                let (left, right) = threads.split_at_mut(next_idx);
                (&mut left[current_idx], &mut right[0])
            } else {
                let (left, right) = threads.split_at_mut(current_idx);
                (&mut right[0], &mut left[next_idx])
            };

            // 有调用函数传入函数入口
            if next_thread.entry.is_some() {
                unsafe {
                    CURRENT_THREAD_ENTRY = next_thread.entry;
                }
            };

            // 状态转变、上下文切换
            if ThreadState::Finished != curr_thread.state {
                curr_thread.state = ThreadState::Ready;
            }

            next_thread.state = ThreadState::Running;
            self.current = next_idx;

            // 天坑,以为切换后还有执行,把关键代码写下面了
            unsafe {
                switch_context(&mut curr_thread.ctx, &next_thread.ctx);
            }    
        }
    }
}

impl TaskContext {
    fn as_mut_ptr(&mut self) -> *mut TaskContext {
        self as *mut TaskContext
    }
    fn as_ptr(&self) -> *const TaskContext {
        self as *const TaskContext
    }
}



/// 模拟让出当前线程
pub fn yield_now() {
    unsafe {
        if !SCHEDULER.is_null() {
            (*SCHEDULER).schedule_next();
        }
    }
}

/// 将当前线程标记为已完成,然后切换到下一个线程
fn thread_finished() {
    unsafe {
        if !SCHEDULER.is_null() {
            let sched = &mut *SCHEDULER;
            sched.threads[sched.current].state = ThreadState::Finished;
            sched.schedule_next();
        }
    }
}

需要注意的是,模拟这里本身的调度器并不是内核里的调度器,其本身也是一个执行流,在首次执行新线程时调度器会被置为Ready,后续代码会停止,当其他任务执行完毕时会变为Finished,但是这时候执行主线程调度的话就会重新变为Ready,然后导致无限循环,所以一定要注意内存序的一个问题:(

4.ABI和上下文切换(主动切换)

正常情况下,如果每次都保存全部上下文的话开销也不小,并且对性能会造成影响,所以ABI将寄存器分为两类,在上下文切换时对于Caller寄存器值不需要系统进行保存,而Callee寄存器值需要系统保存

|---------------------------|--------------|------------------------------------------------------------------------|
| 寄存器类型 | 责任方 | 行为 |
| Caller-saved(易失性寄存器) | 调用者(Caller) | 调用者如果在调用之后还需要这些寄存器中的值,必须在调用前将它们保存到栈上,并在调用后恢复(new)。被调用者可以随意使用它们而不必恢复原值。 |
| Callee-saved(非易失性寄存器) | 被调用者(Callee) | 被调用者如果要用这些寄存器,必须先保存原值(old),并在返回前恢复。这样调用者可以放心,调用返回后这些寄存器的值不变。 |

为什么系统不用保存Caller?

编译器在编译一个函数时,会分析函数内部哪些变量需要跨函数调用保持有效。例如:

cpp 复制代码
void caller() {
    int a = 1;          // a 可能放在某个 Caller-saved 寄存器(如 x86-64 的 rcx)
    callee();           // 调用另一个函数
    printf("%d", a);    // 还要用 a
}

编译器知道callee可能覆盖Caller-saved寄存器(比如rcx),但a在调用后还要用,因此编译器会自动生成代码,在调用callee之前,将a(在寄存器中)保存到栈上的局部变量,调用 callee 之后,再从栈中恢复到寄存器,这一切都是编译器自动完成的,系统无需保存,所以 Caller-saved 寄存器的保存是由编译器在生成调用代码时负责的。

系统为什么保存Callee?

考虑一个主动切换函数 yield(),它由任务主动调用,让出CPU给其他任务。yield()就是一个被调函数,所以它也要遵守调用约定:

  • 作为被调用者(Callee),yield 必须保证在它返回时所有Callee-saved寄存器的值与被调用前相同。

  • 如果 yield 要切换到一个新任务,它不会立即返回,而是保存当前任务的上下文,稍后再通过另一个 yield 切换回来。为了保证将来恢复时 Callee-saved 寄存器的值正确,yield 需要将它们保存到任务控制块中,而不是简单的栈上。

但在被动切换 (例如: 时钟中断),由于中断可能发生在任意指令处,编译器没有为这个点做保存,因此中断处理程序必须保存所有通用寄存器(包括 Caller-saved 和 Callee-saved)。

上下文切换分类

为了保证系统安全,用户态程序不能访问内核栈,所以有各自的栈(用户栈/内核栈),不同架构使用不同的硬件机制实现这种隔离:

  • x86-64:使用 TSS(Task State Segment)结构体存储不同特权级的栈地址,切换特权级时硬件自动切换栈

  • ARMv8:使用不同的栈指针寄存器,SP_EL0 用于用户态,SP_EL1 用于内核态

  • RISC-V:使用 sscratch 寄存器交换栈指针,进入内核态时切换到内核栈

这里提一下用户态转为内核态的这个情况并不属于上下文切换,而是模式切换(mode switch),因为只会提升特权不改变当前进程的地址空间,只是特权级提升,以便执行内核代码。完成后通常返回到同一进程的用户态继续执行。

每个进程/线程有自己的(用户/内核)栈,用于执行内核函数,这是为了隔离和安全,所以会出现换栈的情况,但这不是上下文切换.

例如: linux系统中在内核模块操作用户空间数据会用copy_from_user,这会将用户端的数据复制到内核空间,然后在内核空间执行操作。

cpp 复制代码
int process_user_data(const char __user *user_input, size_t input_len,
                      char __user *user_output, size_t output_len)
{
    char *kernel_buf;
    size_t copy_len;
    int i;

    // 参数检查
    if (input_len > MAX_DATA_SIZE || output_len > MAX_DATA_SIZE)
        return -EINVAL;

    // 在内核空间分配临时缓冲区
    kernel_buf = kmalloc(input_len, GFP_KERNEL);
    if (!kernel_buf)
        return -ENOMEM;

    // 将用户数据安全地拷贝到内核缓冲区
    // copy_from_user 返回未拷贝成功的字节数,0表示全部成功
    if (copy_from_user(kernel_buf, user_input, input_len)) {
        kfree(kernel_buf);
        return -EFAULT;
    }

    // 在内核空间处理数据:这里将每个小写字母转换为大写
    for (i = 0; i < input_len; i++) {
        if (kernel_buf[i] >= 'a' && kernel_buf[i] <= 'z')
            kernel_buf[i] = kernel_buf[i] - 'a' + 'A';
    }

    // 确定要返回的数据长度
    copy_len = (input_len < output_len) ? input_len : output_len;

    // 将处理后的数据拷贝回用户空间
    if (copy_to_user(user_output, kernel_buf, copy_len)) {
        kfree(kernel_buf);
        return -EFAULT; 
    }

    // 释放内核缓冲区
    kfree(kernel_buf);

    // 返回实际拷贝到用户空间的字节数
    return copy_len;
}

按切换对象分类

1.1 进程上下文切换

进程上下文切换是最复杂、开销最大的上下文切换类型,因为它涉及到虚拟内存空间的切换

特点

  • 需要保存和恢复完整的进程上下文,包括用户态和内核态的寄存器

  • 需要切换虚拟内存映射,更新页表寄存器

  • 需要刷新 TLB(Translation Lookaside Buffer),导致内存访问暂时变慢,miss提高

  • 开销较大,通常需要几十到几百个 CPU 周期

场景:不同进程之间的切换,比如从浏览器切换到音乐播放器。

1.2 线程上下文切换

线程上下文切换比进程上下文切换开销小,因为同一进程内的线程共享虚拟内存空间。

特点

  • 同一进程内的线程切换不需要切换虚拟内存空间

  • 不需要刷新 TLB,只需要切换线程的私有数据

  • 开销较小,通常只需要几个到几十个CPU周期

场景:同一进程内的不同线程之间的切换,比如一个 Web 服务器的不同请求处理线程之间的切换。

1.3 中断上下文切换(重要)

中断上下文切换是一种特殊的上下文切换,通知 CPU 有需要立即处理的事件。中断可以分为:

  • 硬件中断:由硬件设备触发,如键盘、鼠标、网卡、硬盘等

  • 软件中断:由软件触发,如系统调用、断点调试等

  • 异常:由CPU执行错误指令触发,如除零错误、缺页异常等

当 CPU 正在处理一个中断时,可能会有另一个更高优先级的中断到来,这就是中断嵌套

为了处理中断嵌套,操作系统使用了特殊的栈管理机制:

  • 每 CPU 独立中断栈:每个CPU核心有自己的中断栈,避免多个中断共享栈导致的问题,但是如果是嵌套中断,同一级别中断嵌套不会切换栈。

  • IST(Interrupt Stack Table):x86 架构中的一种机制,为不同类型的中断指定不同的栈

  • 栈溢出保护:通过设置栈边界检查,避免中断处理导致栈溢出

优点:速度快,性能好

按切换原因分类

2.1 主动上下文切换

主动上下文切换是任务主动放弃CPU的切换方式。

触发条件

  • 进程调用阻塞系统调用,如 sleep ()、wait ()、read () 等

  • 进程主动让出 CPU,如调用 sched_yield ()

  • 进程等待某个资源,如信号量、互斥锁等

特点:进程知道自己要放弃 CPU,会提前做好准备,切换开销相对较小

2.2 被动上下文切换

被动上下文切换是进程被操作系统强制剥夺CPU的切换方式。

触发条件

  • 进程的时间片耗尽

  • 更高优先级的进程就绪

  • 进程被信号中断

特点:进程不知道自己会被切换,可能正在执行重要操作,切换开销相对较大

相关推荐
我爱学习好爱好爱3 小时前
Logstash 数据管道测试案例:从 Filebeat 接收日志并输出至黑屏幕与 Elasticsearch(基于Rocky Linux 9.6)
大数据·linux·elasticsearch
桌面运维家3 小时前
Windows VHD虚拟磁盘技术详解与应用指南
linux·运维·服务器
hy____1233 小时前
Linux_网络基础2
linux·服务器·网络
微露清风3 小时前
系统性学习Linux-第六讲-Ext文件系统
linux·服务器·学习
喵叔哟3 小时前
6. 【Blazor全栈开发实战指南】--组件通信与共享状态
linux·网络·windows
桌面运维家4 小时前
云桌面vDisk解决方案:Windows/Linux高效部署与优化
linux·运维·服务器
wsoz4 小时前
GCC编译
linux·c语言·嵌入式·gcc
xlq223224 小时前
26(下).库的理解与加载
linux·运维·服务器
wbs_scy4 小时前
Linux 动静态库完全指南:制作、使用、原理与实战
linux·运维·服务器