ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

任务切换的本质:CPU如何在栈上完成寄存器搬家

2026/9/17 21:17:45 拓冰建站 浏览量
任务切换的本质:CPU如何在栈上完成寄存器搬家 把“任务切换”这几个字搜一下能搜到一堆概念上下文、TCB、调度器、保存现场、恢复现场。但我在自己动手写过一次 switch 之后才敢说任务切换的本质其实就是 CPU 在栈上做了一次“寄存器搬家”。这话听起来简单真正掰开揉碎去看的时候坑比想象中多。这篇文章想解决一个问题当 CPU 从任务 A 切到任务 B 的那一瞬间它在栈上到底干了什么为什么要这么干以及我们在实际项目里怎么通过栈把切换过程“看到底”。适合正在学 RTOS、准备翻 Linux 内核调度代码、或者是在裸机上自己写多任务玩的朋友。我不会把每个平台的指令都列一遍但会挑最核心的机制配上可以直接上手的代码和排查经验争取让你看完之后能自己画出一张完整的栈布局图。1. 先把任务切换这件事说透1.1 任务切换在切换什么先说结论任务切换切换的是 CPU 的“执行状态”。这个状态不是某个变量也不是某段代码而是一组寄存器的值。CPU 在任意时刻都只能执行一条指令流。指令流走到哪儿了靠 PC程序计数器记住中间计算结果放在通用寄存器里栈顶在哪儿靠 SP栈指针记住上一条指令的运算结果和状态标志在状态寄存器里。这些合在一起就是所谓的“上下文”context。多任务系统能跑起来靠的是把一个又一个任务的上下文轮流交给 CPU。任务 A 跑着跑着来了一个定时器中断CPU 得把 A 的现场保存好然后去执行任务 B。等下次轮到 A 了再把之前保存的现场填回去让 A 接着往下跑就像是中间什么都没发生过一样。所以你看任务切换其实不是一个“切换函数”那么简单。你面对的本质问题是CPU 只有一套寄存器却要服务 N 个任务。那剩下 N-1 套寄存器状态放哪儿答案就是栈。1.2 为什么非要用栈不用全局变量有人会问上下文不就是几十个寄存器的值吗我定义一个全局结构体把寄存器都存进去不也一样这个想法没错很多操作系统的 TCB任务控制块里确实会存放这些值。但这里有个更细的问题在切换发生的那一刹那CPU 自己正在用栈。中断来了它会自动往当前栈上压一些东西调用函数它会自动把返回地址压栈。如果你想用全局结构体保存寄存器你得先手动写一堆 mov 指令把寄存器挨个塞进去。这当然可以但问题是你自己写的保存代码也要占用栈而栈本身的位置和状态恰恰也是你需要保存的内容之一。栈的好处在于它是 CPU 原生支持的、天然的“后进先出”存储结构。PUSH 一条指令就能把一个寄存器压进内存POP 一条指令就能把它弹回来。而且每个任务都有自己的栈任务之间的数据天然隔离。你不需要为“任务 A 的 r4 和任务 B 的 r4 会不会冲突”操心因为切栈就是切了整个任务的工作台面。全局变量做不到这种隔离你得用一个复杂的数据结构去模拟“哪个值属于谁”而栈直接用 SP 一个指针就解决了。另外栈还天然支持嵌套。任务切换经常发生在中断里中断里又可能发生另一层切换比如 PendSV 的典型用法栈的后进先出特性正好能撑住这种层层嵌套的现场保存。这一点用全局变量来做复杂度会指数级上升。1.3 在一个完整的软件技术栈里任务切换处在哪一层平时大家聊“技术栈”多半指应用开发那一套前端框架、后端语言、数据库、中间件。但在操作系统这个技术栈里任务切换属于内核里最底层的那一环它是调度器、中断管理、进程管理这些模块的“地基”。大概的位置是这样的硬件中断在最底层往上是最简单的上下文切换原语switch_to再往上是调度器策略谁来接替当前任务然后才轮到各种进程/线程管理逻辑。不管你是用 FreeRTOS、RT-Thread还是直接看 Linux 的 schedule()底层的动作都是一样的调一个切栈函数把旧任务的栈指针存起来把新任务的栈指针装进 SP。搞清楚这一层上面那些花花绕绕就都看得懂了。2. 栈上的完整“时间线”一次切换的前后对比2.1 触发中断和系统调用是怎么把 CPU 引到切换入口的任务切换不是凭空发生的。在抢占式调度里最常见的触发源是定时器中断。定时器到点CPU 硬件检测到中断信号会自动做一串动作把当前任务的执行“冻结”住然后跳到中断处理程序。以 x86_64 为例一次中断让 CPU 在硬件层面自动做了这些事情根据中断向量表找到入口地址把当前任务的 SS、RSP、RFLAGS、CS、RIP 依次压入当前栈如果发生了特权级切换还会从 TSS 里加载一个新的栈指针。也就是说在你的代码还没跑之前CPU 自己已经在栈上写入了 5 个值。而在 ARM Cortex-M 上类似中断来的时候硬件会把 xPSR、PC、LR、R12、R3-R0 自动压入当前栈。这就是所谓的“硬件自动压栈”。理解了这一步你会发现一个关键点任务切换时栈上最早出现的那些数据不是软件代码写的而是 CPU 硬件“自作主张”压进去的。所以我们写上下文切换代码时必须知道自己是在跟“已经有一批数据躺在栈上”的状态打交道。2.2 保存阶段寄存器是怎么变成栈里的数据的进入中断处理程序之后软件接管。为了防止接下来的操作破坏通用寄存器第一步就是把剩下的寄存器全部压栈。在 Linux 的 entry 代码里你会看到一堆 push 指令把 r15、r14、r13、r12、rbx、rbp 这些寄存器挨个压进去。压完之后当前任务的“现场”就以栈帧的形式躺在内存里了。接着调度器上场选出下一个要运行的任务 B。这时候 CPU 站在任务 A 的栈顶上手里拿着 A 的 SP。调度器要做的事情就是调用一个 switch 函数。这个函数会再压一批寄存器然后把当前的 SP 保存到 A 的 TCB 里再从 B 的 TCB 里取出 B 的 SP最后把 B 的栈上的数据弹回寄存器。这里有句非常重要的话栈指针一旦切换CPU 的 push 和 pop 操作就会全部落到新任务的栈上。旧任务的栈不会消失它只是“冻住了”。就像你把工作台上的图纸收进抽屉换了另一张图纸出来旧图纸还在抽屉里等你需要的时候再拿。2.3 恢复阶段栈里的数据怎么变回寄存器从 B 的 TCB 里取出 SP加载进 CPU 的 SP 寄存器这一步执行完CPU 的“当前栈”就变成了 B 的栈。接下来的恢复就是保存的逆过程把之前 B 被切走时压在栈上的寄存器逐个 pop 回 CPU最后执行返回指令把之前压入的返回地址弹进 PC。CPU 就乖乖地从 B 被打断的地方继续跑了。整个过程里你仔细品一下CPU 自己并没有“记住”任何关于 A 和 B 的信息。它只知道当前 SP 指向哪儿跟 SP 走。所有的记忆都落在了栈上。所以“任务切换”四个字本质就是“换栈”。2.4 一张栈布局图看懂全程用一个不严谨但好懂的图把这段总结一下。假设栈向下增长高地址在最上面高地址 ----------------------------- | 任务入口地址/返回地址 | - CPU 回到这里继续执行 | 状态寄存器 | | 被保存的通用寄存器 | | 硬件自动压入的现场 | | ... 其他局部变量/调用帧 ... | ----------------------------- - 当前 SP 低地址任务 A 被切走前SP 停在 A 的栈的某个位置任务 B 被恢复后SP 指向 B 的栈的对应位置。两个任务各用各的栈切换只是把 SP 这个“指针”拨到另一边。这就是整个机制的全部骨架。3. 手写最小任务切换让栈自己说话看再多理论不如自己写一个。下面我挑 x86_64 的写法做演示因为它的 push/pop 指令语义最直接理解之后看 ARM 也就是换几个寄存器名字的事。代码是示意性质的去掉了很多真实内核里的细节但核心逻辑完整。3.1 先准备任务控制块和任务栈每个任务至少需要两块东西一块内存当栈一个变量保存它当前的栈指针。#define STACK_SIZE 8192 typedef struct { uint64_t *sp; // 当前任务的栈指针 uint8_t stack[STACK_SIZE]; // 任务栈内存 } task_t; task_t task_a, task_b;栈数组放在结构体里方便管理。sp 字段就是我们要保存和恢复的关键它会在 switch 的时候被写入/读出。3.2 核心汇编 switch_to 的每一行假设我们需要从当前任务切换到另一个任务函数原型可以写成void switch_to(task_t *next);注意真实内核里通常要同时传入 prev 和 next因为要把当前任务的 sp 存进去。这里为了简洁我用一个全局变量记录当前任务示意如下task_t *current; void switch_to(task_t *next) { asm volatile( pushq %rbp\n pushq %rbx\n pushq %r12\n pushq %r13\n pushq %r14\n pushq %r15\n movq %%rsp, %0\n // 把当前 sp 保存到 current-sp movq %1, %%rsp\n // 把 next-sp 加载进 rsp popq %r15\n popq %r14\n popq %r13\n popq %r12\n popq %rbx\n popq %rbp\n ret\n : m(current-sp) : m(next-sp) : memory); }我们一行一行拆前面六个pushq是把 callee-saved 寄存器压入当前任务的栈。为什么要压这几个因为调用约定规定被调函数必须保证这些寄存器在被调用前后不变。switch 本身也是一个函数所以在它的入口处要把可能修改的寄存器保存好。movq %rsp, current-sp把当前 SP 存起来这就完成了任务 A 的现场保存。movq next-sp, %rsp把任务 B 的栈指针装进 CPU。从这一行开始CPU 眼中的“栈”已经变成任务 B 的栈了。之后的任何 push/pop/call/ret 都会落在 B 的栈上。后面的popq是恢复任务 B 之前保存的寄存器。ret做的事情是从当前栈顶部弹出一个值放进 RIP。这个值就是任务 B 上次被切走时压进去的返回地址。如果 B 是第一次运行这个值就是我们初始化时“伪装”进去的任务入口地址。你可能注意到我们没有显式保存 PC。因为 PC 的保存和恢复是靠call/ret这对指令隐式完成的。调用switch_to时CPU 会把返回地址压在栈上ret时又把返回地址弹出来。我们的任务是在初始化的时候往新任务的栈上预先放好一个“假返回地址”让ret直接跳进任务入口。3.3 创建任务时如何把入口“伪装”到栈上新任务从来没被切走过它的栈应该是空的。但我们希望它第一次被切换进来的时候ret能够跳到task_entry去执行。所以我们要手动把栈初始化成“看起来像是刚被 switch_to 压过栈的样子”。void task_init(task_t *t, void (*entry)(void)) { // 让栈指针从栈顶开始向下增长 uint64_t *sp (uint64_t *)(t-stack STACK_SIZE); // 模拟“刚被切走”的栈布局 *--sp (uint64_t)entry; // ret 时弹出的地址也就是任务入口 *--sp 0; // r15 *--sp 0; // r14 *--sp 0; // r13 *--sp 0; // r12 *--sp 0; // rbx *--sp 0; // rbp t-sp sp; }初始化完任务 B 的栈顶是 rbp 的占位值再往上是 rbx、r12……一直到入口地址。当切换发生时popq依次弹出 6 个占位寄存器最后ret弹出入口地址CPU 就跳进了entry。整个过程就像任务 B 之前已经跑过、刚刚被切走一样。这就是新任务“出生”的秘密。如果你用的架构是 ARM 或 RISC-V套路完全一样只不过要保存的寄存器集合不同、压栈顺序不同。比如 ARM 是push {r4-r11, lr}返回地址用的是lr而不是ret但思想同源。理解一个再对照参考手册很快就能写另一个。4. 实战中的栈细节这些坑我都踩过4.1 任务栈大小怎么定栈水位线怎么测任务栈的大小是个让人头疼的问题。定小了运行一段时间栈溢出程序神不知鬼不觉地跑飞定大了内存浪费嵌入式板子上根本放不下几十个任务。我习惯的做法是“先粗后细”第一版根据任务的函数调用深度估算给一个看起来宽裕的值比如 2KB 或 8KB然后在栈底填充一段特征字节比如 0xCC跑完各种恶劣场景后从栈底往高地址扫描看特征字节被破坏的最高位置。那个位置到栈底的距离就是任务栈的实际最大用量也就是“水位线”。#define STACK_FILL_PATTERN 0xCC void stack_watermark_init(task_t *t) { memset(t-stack, STACK_FILL_PATTERN, STACK_SIZE); } uint32_t stack_watermark_used(task_t *t) { uint32_t used 0; for (uint32_t i 0; i STACK_SIZE; i) { if (t-stack[i] ! STACK_FILL_PATTERN) { used STACK_SIZE - i; } } return used; }这个“栈水位线”是我排查栈问题最先看的指标。如果实际使用量已经超过栈大小的 80%我基本会直接加大栈绝不留隐患。因为任务切换还涉及中断嵌套那些嵌套现场也会压在任务栈上测试时一定要把最极端的嵌套路径跑出来。4.2 栈对齐、生长方向、初始化的三个细节三个细节看起来小每个都能让你调一晚上。第一个是栈对齐。x86_64 的 ABI 要求函数调用时 RSP 按 16 字节对齐很多 SIMD 指令也要求内存对齐。如果你初始化 task-sp 的时候少算了几字节任务第一次进去可能没事但一旦调用到需要对齐的函数立刻崩溃。所以初始化栈时最好在最后对 sp 做一次对齐调整t-sp (uint64_t *)((uint64_t)sp ~0xFULL);第二个是生长方向。x86 和 ARM 的栈都是向下生长的但有些教学平台或者 RISC-V 的实现细节略有差别。如果你把初始化方向搞反了push 的时候会把入口地址写到栈外访问到非法内存。而且别假设所有架构都一样拿到新平台先看编译器手册。第三个是初始化顺序。伪造栈帧的时候压入顺序必须和你 switch 汇编里的恢复顺序完全相反。先压哪条、后压哪条一步错了恢复出来的寄存器就是错位的数据。这种错误特别隐蔽因为栈内存不代表非法地址它只是“数值错乱”跑一段时间才会在某个意想不到的地方爆炸。我调试过最莫名其妙的一例是一个任务在恢复后把局部变量当函数指针调了折腾了一天才发现是初始化栈时少压了一个占位寄存器。4.3 调度时机不同栈的表现完全不同任务切换发生的时机决定了一部分现场是谁压进去的。假如你的系统是协作式调度任务主动调用task_yield()让出 CPU。这种情况下切换发生在普通函数调用的过程中现场就是软件保存的那一批寄存器栈上不会出现硬件自动压栈的那些中断现场。返回路径也很干净就是从最内层的 switch 函数一层层返回。假如是抢占式调度定时器中断随时可能打断任务的任意一条指令。中断入口的代码会先保存硬件规定的那些寄存器然后才进入调度器。此时栈上不仅有软件保存的寄存器还有硬件自动压入的 PC、状态字等。恢复的时候如果是普通上下文靠ret就能回去如果是中断上下文可能需要iret或 ARM 的异常返回指令EXC_RETURN。这里有个特别需要注意的坑不要在中断处理函数里直接调用switch_to。因为中断返回时CPU 会用中断栈上的现场做特殊处理如果你在中断里强行切了 SP等到中断返回执行的却是另一个任务的代码现场会变得非常混乱。在 Cortex-M 上常规做法是“设置一个切换标志触发 PendSV在 PendSV 里做切换”。PendSV 是专门为上下文切换设计的异常它会在所有其他中断处理完之后才运行这样既保证了中断现场完整又统一了切换路径。5. 问题排查与经验速查5.1 切换后跑飞先查栈任务切换相关的崩溃形态千奇百怪但根因八九不离十都跟栈有关栈溢出、栈指针错位、栈帧伪造错误、中断现场被冲掉。我总结了一个排查顺序遇到“切换后跑飞”“随机死机”“HardFault”这类问题照着走基本能缩短大半时间。第一步看当前 SP 指向哪里。如果 SP 指向了非法的内存区域比如未映射地址、只读 flash那基本可以断定任务栈或 TCB 里的 sp 字段写错了。用调试器读一下current-sp和next-sp对照两个任务的栈地址范围很容易发现问题。第二步看栈里的数据。对嵌入式平台出问题时用调试器把 SP 附近的 32 字节 dump 出来。如果你能看到一条像函数地址、或一堆 0xA5A5A5A5 之类的填充字节说明栈很大概率是被写穿了或者弹出顺序不对。第三步查异常现场。Cortex-M 上有 SCB-CFSR、SCB-MMFAR 这些寄存器能告诉你是因为总线错误、还是因为未对齐访问导致的 HardFault。x86 上则要看异常向量号和错误码。很多时候崩溃的真正原因是一两行之前的数据访问越界那个越界的内存恰好把任务栈的返回地址破坏了。5.2 必查清单从栈指针到返回地址下面这张表是我做任务切换调试时的速查清单检查项反映的问题典型原因current-sp是否落在任务栈范围内TCB 的 sp 保存是否正确保存时机不对或 current 指针弄错新任务第一次切入时栈里有没有入口地址初始栈伪造是否正确压栈顺序错或 sp 初始值错恢复后的 PC 是否是预期的函数地址返回地址是否正确弹出栈里内容被冲掉或 pop 顺序错栈底特征字节有没有被破坏是否发生栈溢出栈开太小中断嵌套过深切换时中断有没有开着恢复的中断现场是否完整中断里直接切栈或被关中断如果以上几项都正常但问题还在那就要考虑不是切换本身的问题而是任务功能代码里已经发生了内存越界。比如数组越界把相邻的任务栈给踩了。这种问题最隐蔽因为表面看是任务切换死机实际是业务代码埋的雷。遇到这种情况可以临时把任务栈之间的区域填充不同的特征字节比如任务 A 的栈底填 0xAA任务 B 的填 0x55跑挂后看哪片被破坏就能定位是哪个方向越界。5.3 经验备忘我在项目里的一次栈定位最后分享一个真实案例。有一次我在一款 Cortex-M 平台上调试双任务调度现象很诡异任务 A 运行正常任务 B 跑不到 10 秒必然 HardFault。一开始我怀疑是任务 B 的代码逻辑问题但把任务 B 的代码逐行 review 过也没发现问题。后来我用调试器围观了一次崩溃现场HardFault 进到异常处理时SP 指向的地址离任务 B 的栈顶只差 4 个字节栈里的返回地址是一个明显不对的 0xFFFFFFFF。我再往前翻任务 B 栈里的历史数据发现有一块很密集的局部变量数组大小有 512 字节而任务 B 的总栈大小只有 1KB。也就是说这个任务里某个函数在栈上分配了巨大的局部数组再加上中断嵌套压栈直接把栈顶挤穿了。解决方式很简单把任务 B 的栈从 1KB 加到 4KB并且加上了栈水位线检测。但这还不是最值得说的最值得说的是排查思路我没有盲目去改任务代码而是先把“栈”这个主角的现场看清楚再顺着栈里的蛛丝马迹找源头。任务切换问题栈永远是最重要的现场。你如果现在正要自己写一个协程库、移植一个 RTOS或者刚开始读 Linux 调度代码别急着追求什么高大上的调度算法。先花一晚上把自己手底下那个最朴素的 switch 函数跑通把栈布局图画出来把任务初始化时的“假现场”写明白。这关过了再去看那些复杂的调度器你会觉得整个世界都清晰了。