☰
内核栈切换全链路:TSS、switch_to与软件栈切换实战
2026/10/27 4:53:52 网站建设 项目流程

内核栈的切换,是操作系统课程实验里最容易被“看懂却做不出来”卡住的一关。屏幕上代码不过几十行,switch_to一个宏、TSS 里几个字段、schedule()里一次调用,看起来一目了然;可真正把打印加进去、把寄存器打出来、把它跑起来,才发现自己对“栈到底什么时候换、谁换的、换到哪去”这件事根本没有建立起闭环。这篇东西就是围绕内核栈切换这个主题,把从硬件行为到源码实现、从观测手段到手工改造的完整链路拆开讲,重点放在原理和实操的咬合处,尽量少讲空话。适合三类人看:正在做这套基于 0.11 教学内核实验、被进程切换卡住的同学;写过单片机裸机代码、想搞清楚保护模式下特权级栈切换机制的朋友;以及想弄清现代内核为什么放弃硬件任务切换、改用软件栈切换的从业者。文中所有代码片段都基于常见教学内核版本,标注为示意的部分需要你对照自己手上的源码逐字段核实,不要照抄。

1. 栈不是一根:先把用户栈、内核栈和它们的交界处搞清楚

1.1 一次系统调用背后,CPU 悄悄换了两次栈

很多人对“栈切换”的困惑,源于一个错误默认:以为一个进程自始至终只有一根栈。实际上在保护模式的操作系统里,一个运行中的进程至少有两条独立生长的栈——用户态用的用户栈,和进入内核后用的内核栈,它们位于不同的内存区域,由不同的段寄存器和栈指针描述。更关键的是,这两条栈之间的切换不是你写代码完成的,是 CPU 在执行特权级变化时自动完成的,你只是在事后接管现场。

具体流程是这样的:用户程序执行int 0x80,CPU 根据 IDT 中的门描述符发现目标代码段 DPL 为 0,判定发生特权级提升。此时它不会傻乎乎地继续用用户栈——让内核代码跑在用户可控的栈上是灾难性的,栈上任何一个字节都可能被用户程序伪造。CPU 会去当前任务寄存器 TR 指向的 TSS 里,取出预先存好的ss0和esp0,把这两个值装进 SS 和 ESP,于是栈指针瞬间跳到内核栈上;紧接着,CPU 把用户态的ss、esp、eflags、cs、eip依次压到这条新栈上,作为将来返回用户态的凭据。

所以在一次最简单的系统调用里,栈发生了两次变化:进入内核时,栈从用户栈切到内核栈;iret返回时,CPU 从内核栈顶端弹出那五个值,栈又切回用户栈。你们实验里要处理的“内核栈的切换”,主体是第二条线——多个进程共用一个 CPU 时,A 进程的内核栈怎么变成 B 进程的内核栈。前一次切换是硬件按 TSS 做的,后一次在 0.11 里同样是硬件按 TSS 做的,只不过换了个 TSS 而已。把这两件事分清楚,后面的源码就不会看串。

还有一点常被忽略:如果中断或异常发生在内核态,也就是说 CPL 本来就是 0,CPU 不会去读ss0/esp0,而是继续在当前内核栈上压栈。这解释了很多实验现象——为什么内核里嵌套调用时栈会越压越深,为什么深递归或者大局部数组会把内核栈撑爆。理解“同特权级不换栈”这条规则,你排查内核栈溢出时才有正确的心智模型。

1.2 0.11 里内核栈长在 task_struct 的同一页上

教学内核的经典设计是:每个任务的描述符结构task_struct和它的内核栈共用一个 4KB 物理页,用一个联合体表达。这个设计的用意非常朴素——省内存,也省一次页分配。源码大致长这样(示意,字段名按你手上的版本核对):

union task_union { struct task_struct task; /* 位于页首 */ char stack[PAGE_SIZE]; /* 栈从页尾向低地址生长 */ };

栈是向下生长的,也就是从页尾(页起始地址 + 4096)往页首方向长。而task_struct正好蹲在页首、也就是这条栈的“最深处”。于是tss.esp0的赋值就非常直白:

p->tss.esp0 = PAGE_SIZE + (long) p; /* p 指向页首,加 4KB 就是栈顶 */ p->tss.ss0 = 0x10; /* 内核数据段选择子 */

这两行的含义是:当这个任务在用户态被中断或陷入内核时,CPU 把栈指针设为这个任务 4KB 页的页尾,段寄存器设为内核数据段。注意这里是“栈顶”,栈还没被压任何东西,第一次压栈会写到页尾下方 4 字节处。

这个布局带来的第一个直接后果,也是我认为整个实验里最有价值的知识点:内核栈向下生长,task_struct就在栈底。一旦内核栈溢出,第一个被踩烂的不是别人,正是这个任务的state、counter、priority、signal这些字段。现象是什么?任务莫名其妙不再被调度、时间片计数错乱、信号处理行为诡异,而崩溃点可能在十万八千里外的另一个函数里。这就是典型的“现场丢失型”故障,也是很多人实验做到一半发现系统“随机死”却查不出原因的根源。

我把这个布局画在纸上贴在显示器旁边,用了一整晚才真正体会到它的意义。后来每次看到有人问“为什么我的内核跑一会儿就卡死”,我第一反应都是问一句:你内核栈里是不是放了太大的局部数组?

1.3 为什么每个任务必须有独立的内核栈

有一种偷懒的想法很自然:既然每次进入内核都从用户栈切过来,那干脆所有任务共用一个内核栈行不行?不行,原因有两个层次。

第一层是并发语义。进程切换的本质是“一个执行流被按下暂停键,另一个执行流从暂停处继续”。A 进程在系统调用中途被时钟中断打断,它的调用链、局部变量、返回地址全都在自己的内核栈上,这些是它恢复执行的全部凭据。如果 B 进程用同一条栈继续压栈,A 的现场就会被覆盖,等再切回 A 时,返回地址已经是别人的了,CPU 会跳到一个毫无意义的地址上。所以内核栈必须随任务一起被保存和恢复,这就是为什么它被放在task_struct旁边。

第二层是特权级安全边界。内核栈上存着从用户态传来的参数、系统调用的返回地址、中断现场。如果这些数据落在用户程序能够读写的地方,用户随便改一个返回地址就能跳到内核任意位置执行,整个保护机制形同虚设。把内核栈放在内核数据段(DPL 为 0),用户态代码根本无法寻址到它,这是整个保护模式能立住的基础之一。

顺带说一个观察:现代内核里task_struct通常不再和内核栈挤在同一页,而是用thread_info或者干脆把栈单独分配,config 里还有THREAD_INFO_IN_TASK之类的选项,目的就是把“栈溢出立刻踩烂调度信息”这个风险降下来。教学内核选择合用一页,是内存紧张年代的产物,理解它的取舍比背下结构体定义更有用。

2. 源码三层设计:TSS、描述符与 switch_to

2.1 TSS 里的 esp0/ss0 是特权级跳板的支点

任务状态段 TSS 在 0.11 里承担了两个角色:一是存放任务在硬件任务切换时需要保存/恢复的完整寄存器快照,二是提供“特权级跳板”所需的内核栈信息。第一个角色是历史包袱,第二个角色至今仍在沿用——即便在今天的内核里,TSS 也依然存在,只是退化成只用来存rsp0和 IO 位图。

对于内核栈切换这个主题,真正要盯住的是esp0和ss0这两个字段。它们的语义是“当本任务从低特权级进入 0 特权级时,CPU 应该把栈切到哪个位置”。所以每一次任务切换成功之后,新任务的esp0必须已经是正确的——这不是可选项,是硬性前提。0.11 里这个值在copy_process中一次性算好,因为内核栈位置在整个任务生命周期内不会变。

但这里有一个很多人栽过的坑:如果你在实验里做了“内核栈重新分配”之类的改造,或者 fork 之后手工动过task_union的地址,却忘了同步更新tss.esp0,那么现象会非常迷惑——进程正常被调度、正常运行、打印也正常,可一旦它发起系统调用,系统立刻 triple fault 重启。因为在 CPL 由 3 变 0 的那一刻,CPU 拿着过期的esp0往一块已经不归这个任务所有的内存上压了五个值,什么都没报,直到后面的执行撞上不可恢复的错误。

注意:改内核栈位置必须同时改三处——task_union的分配、tss.esp0的赋值、以及任何缓存了栈顶地址的代码。三者必须原子地保持一致,只改一处就等于埋雷。

再说ss0。它固定是内核数据段选择子(0x10),因为内核栈只能落在内核数据段里。这个值在整个系统生命周期里都不该变,如果你发现自己需要动它,说明前面的内存布局设计出了问题。

2.2 GDT 里的 TSS/LDT 描述符与 _TSS 宏的地址算法

硬件任务切换要能工作,当前任务的 TSS 必须被 GDT 里一个专门的描述符指向,而 TR 寄存器则保存着这个描述符的选择子。教学内核在 GDT 里给每个任务预留了两个连续的描述符槽位:一个放 TSS,一个放 LDT。索引算法是这样的:

#define FIRST_TSS_ENTRY 4 #define FIRST_LDT_ENTRY (FIRST_TSS_ENTRY + 1) #define _TSS(n) ((((unsigned long) n) << 4) + (FIRST_TSS_ENTRY << 3)) #define _LDT(n) ((((unsigned long) n) << 4) + (FIRST_LDT_ENTRY << 3))

n << 4是因为每个任务占两个描述符、每个描述符 8 字节,合计 16 字节,所以任务号乘以 16 就是它那两个槽位的字节偏移。FIRST_TSS_ENTRY << 3是起始槽位对应的字节偏移(描述符索引乘 8)。得到的结果既可以直接当作 GDT 中的字节偏移去定位描述符,也可以直接当作段选择子使用——因为选择子的高 13 位就是描述符索引,低 3 位是 TI 和 RPL,而这里 TI 为 0、RPL 为 0。

这个算式初看有点绕,其实把它拆成“字节偏移 = 索引 × 8”就通了。真正容易出错的地方在于:给ljmp用作操作数的“选择子”和用来算内存地址的“偏移”在数值上恰好一致,导致很多人误以为它们是一回事,改代码时随手加了个 RPL,跳转就失灵了。

还有一个细节常被忽略:这些描述符不是启动时就给所有任务都填好的。初始化阶段通常只建立 0 号任务的描述符,其余槽位置零;每个新任务在copy_process里把自己的 TSS/LDT 描述符写进 GDT 对应位置,同时把 TSS 里的 LDT 字段填成自己的 LDT 选择子。所以如果你在启动早期就ljmp到一个还没初始化的任务槽,会触发通用保护异常——描述符的 present 位是 0。

2.3 一条 ljmp 触发的硬件级任务切换

0.11 里最“魔法”的一行代码,就是这个宏(示意,与实际版本字段顺序可能有差异):

#define switch_to(n) { \ struct { long a, b; } __tmp; \ __asm__("cmpl %%ecx, current\n\t" \ "je 1f\n\t" \ "movw %%dx, %1\n\t" \ "xchgl %%ecx, current\n\t" \ "ljmp *%0\n\t" \ "cmpl %%ecx, last_task_used_math\n\t" \ "jne 1f\n\t" \ "clts\n" \ "1:" \ ::"m" (*&__tmp.a), "m" (*&__tmp.b), \ "d" (_TSS(n)), "c" ((long) task[n])); \ }

这段内联汇编做的事,用大白话讲:先比较目标是不是就是当前任务,是就跳过;否则把 TSS 选择子塞进临时结构体的段选择子字段,用xchgl原子地把current换成新任务指针;然后一条ljmp *%0远跳转过去,操作数指向的正是那个装着“偏移 + 选择子”的临时结构。

当ljmp的目标选择子指向的是一个 TSS 描述符(描述符类型字段标明为可用 32 位 TSS),CPU 不会像普通远跳转那样跳到某个代码地址,而是执行一次完整任务切换。它做的事可以概括成三步:把当前所有通用寄存器、段寄存器、EFLAGS、EIP 写回 TR 指向的旧 TSS;从目标 TSS 里把同样的内容读出来装进寄存器;同时按需更新 LDTR、CR3、TR 以及 EFLAGS 的 NT 位。

请注意这里发生了什么:内核栈指针 ESP 只是被保存/恢复的众多寄存器之一。也就是说,所谓“内核栈的切换”,在 0.11 里根本没有独立的代码,它是硬件任务切换的副产品。这一点想通了,实验里很多疑问会自动消失——为什么switch_to里看不到任何和栈相关的指令?为什么新任务第一次被调度时不需要你手动布置现场?因为 CPU 全包了。

代价也很明显。硬件任务切换要读写一整个 TSS,涉及上百字节的内存访问,还要切换 LDTR、可能切换 CR3,开销在几十到上百个时钟周期;对于每秒切换上千次的调度器来说,这笔账不划算。更重要的是,它把“保存哪些寄存器”的决定权交给了硬件,内核无法只保存自己需要的那几个。这正是后来所有主流内核都改成软件栈切换的原因,也是你们实验里如果被要求“用栈的方式重写切换”的由来。

3. 动手改:从观测到手工切换

3.1 环境准备与最小验证路线

动手之前,先明确一点:这类实验最大的风险不是你写错代码,而是“改了不知道有没有生效”。所以第一步永远不是写功能,而是建立观测能力。我通常的做法是把验证拆成三级,一级一级往上走,每级都留一个可回退的检查点。

第一级是静态验证。改完代码,先别急着跑,用反汇编工具把编译出来的目标文件看一眼,确认switch_to展开后的指令序列符合预期,ljmp的操作数确实指向你算出来的那个选择子。这一步花不了五分钟,却能挡掉一大半“改了没生效”的尴尬。

第二级是日志验证。在schedule()里、在switch_to前后、在系统调用入口各加一处打印,输出当前任务号、current指针、current->tss.esp0、当前 ESP 值。跑起来对着日志看,能不能观察到“任务号变化 → esp0 随之变化 → ESP 跳到新页尾下方”这条因果链。如果连链条都观察不到,后面所有改造都是盲改。

第三级才是行为验证。让多个任务交替跑,每个任务在自己的循环里打印计数,观察是否真的在并发推进;再故意构造一个会阻塞的系统调用,看重调度是否正常。

环境上,用带调试器的模拟器是最优解。模拟器可以随时暂停、单步、查看寄存器和内存,比在真机或者纯虚拟机里靠打印猜要高效得多。配置的时候记得打开符号信息,至少让current、task、switch_to这些符号在调试器里可见,否则你只能对着十六进制地址发呆。

提示:实验环境里的时间片通常按 10ms 的节拍推进,也就是说一个任务一次最多跑 10ms 就会被换下。这意味着你加的打印必须足够短,否则打印本身就会改变调度时序,让你误以为系统行为异常。打印量大时,先关掉无关输出。

3.2 加打印:观测点选在哪、看什么值

观测点选得对不对,直接决定你能不能在半小时内定位问题。我给一个自己常用的最小观测集,四处打印,覆盖一次完整切换的前后:

第一处放在schedule()选出下一个任务之后、调用switch_to之前。打印旧任务号、新任务号、新任务的tss.esp0、新任务的tss.eip。这一处是“切换意图”,能看出调度器选得对不对。

第二处放在switch_to宏内部,ljmp之前。打印当前 ESP 的值。这一处是“切换前一刻的真实栈指针”,是判断栈到底有没有换的关键证据。

第三处放在新任务第一次被调度起来之后的第一条内核代码路径上(通常是系统调用返回路径或者一个专门的内核线程入口)。打印当前任务号和当前 ESP。这一处是“切换后的落点”,和第一处的新任务esp0一对,就能确认栈是不是真的换到了预期位置。

第四处放在sys_call入口,打印进入内核瞬间的 ESP 和 SS。这一处用来验证ss0/esp0是否被正确使用,是排查“系统调用一进就崩”的那把钥匙。

这四处打印的内容互不重复,串起来就是一次切换的完整轨迹。我试过只打第一处和第三处,结果在一次“任务被调度但栈没换”的诡异现象里卡了很久——因为从第一处和第三处的输出看你以为换过去了,实际上那只是任务号变了,ESP 还停在原地。加上第二处之后,问题一眼就看出来了。

关于“看什么值”,再补充一点经验:ESP 的绝对值本身意义不大,真正有意义的是它和所属 4KB 页的关系。我一般会同时打印ESP >> 12(页号)和current的页号,两个数字一对比就知道栈是不是落在当前任务自己的那一页上。这个技巧在排查跨任务栈串用时非常好使。

3.3 用软件方式接管栈切换(进阶)

如果实验要求你摆脱 TSS 硬件切换、用软件方式完成内核栈切换,那真正的难点不在“怎么换栈指针”,而在“新任务第一次被调度时栈里有什么”。原来的硬件切换方案里,CPU 会按 TSS 把寄存器全装好,新任务自然而然地从某个入口开始跑;一旦改成软件切换,这层保底就没了,你必须自己伪造一个“看起来像刚刚被保存过”的现场。

思路分三步。第一步,切换到目标任务之前,把当前任务的现场保存到它自己的task_struct里,至少保存 ESP,通常还要保存 EBP、EBX、ESI、EDI 这几个调用者保存寄存器。

/* 示意代码,仅表达思路,寄存器清单需按实际 ABI 调整 */ __asm__ volatile( "pushl %%ebp\n\t" "pushl %%ebx\n\t" "pushl %%esi\n\t" "pushl %%edi\n\t" "movl %%esp, %0\n\t" /* 当前现场存入当前任务 */ "movl %2, %%esp\n\t" /* 切到目标任务的内核栈 */ "popl %%edi\n\t" "popl %%esi\n\t" "popl %%ebx\n\t" "popl %%ebp\n\t" : "=m"(prev->saved_esp) : "m"(next->saved_esp), "r"(next->saved_esp) : "memory");

第二步,让新建任务在被 fork 出来时,就在自己的内核栈顶伪造好这样一段内容:四个寄存器的占位值,加一个“返回地址”,也就是这个任务第一次被切进来之后应该跳去哪里。通常这个地址指向进程进入用户态的统一出口,这样新任务一被调度,就好像刚从一个系统调用返回一样,顺畅地落进用户态。

第三步,把tss.esp0维持在页尾不动,因为它只在特权级提升时被硬件读取,和你软件怎么切栈是两回事。这一点很多人会混淆,以为软件切换以后 TSS 就没用了,其实 TSS 的esp0/ss0仍然在每一次系统调用时被 CPU 使用,动了就崩。

两个必须注意的点。其一,软件切换只能在所有任务共享同一套页表的假设下交换 ESP,因为 ESP 里存的是当前段内的偏移,如果切换同时换了页表,落点就可能映射到不同的物理内存。教学内核的内核区是共享的,所以没问题,但你要清楚这个前提。其二,只要任务各自有不同的 LDT,软件切换就必须显式刷新 LDTR,否则之后所有用户态段寄存器的解析都会用错段表,表现为用户程序一跑就异常。硬件切换时代这一步是 CPU 自动做的,改成软件之后就落到你头上了。

/* 软件切换后必须补上,示意 */ __asm__ volatile("lldt %0" :: "r"(_LDT(n)));

写这段代码的时候,我在lldt这一行上吃了大亏。第一次跑起来所有任务都正常调度、打印也正常,但一碰到用户态访问内存就出异常。折腾很久才反应过来:任务号变了、栈换了、寄存器恢复了,唯独 LDTR 还指着上一个任务的段表。这个错误特别隐蔽,因为内核态用的段选择子都是固定的几个,完全不经过 LDT,所以你在内核里怎么跑都没事,一切换到用户态立刻炸。

4. 踩坑实录与排查对照表

4.1 崩溃现场怎么读

内核调试和用户态调试最大的区别是:用户态程序崩了有信号、有栈回溯;内核崩了往往只有三行十六进制,或者干脆静默重启。所以第一件事是学会从有限信息里读出方向。

模拟器里常见的三类现场。第一类是通用保护异常,错误码里带着选择子,指向某个段寄存器。这种八成是段选择子算错了,或者描述符的 present 位、DPL 不对。第二类是缺页异常,错误码里带着读写位和地址,这种要往栈指针上想——ESP 指到了没映射的地方,压栈就缺页。第三类是三重故障直接重启,这种通常意味着异常处理本身又出了异常,最常见的原因是esp0配错导致进入异常处理时栈就是坏的。

我的习惯是先把出错时的寄存器全量 dump 下来,重点看 EIP、ESP、SS、CS、TR、LDTR 六个值。EIP 告诉你在哪炸的,ESP 告诉你栈在哪,SS 确认栈在哪个段,CS 确认当前特权级,TR 确认当前是哪个任务,LDTR 确认段表对不对。这六个值凑一起,绝大多数问题能直接定位。

再补一个经验:不要一上来就怀疑编译器或者工具链。这类实验里 90% 的问题都是自己代码里的选择子、偏移、顺序问题,工具链的锅少之又少。

4.2 内核栈溢出:最隐蔽的那一撞

前面提过,task_struct就在栈底。这意味着内核栈溢出不会立刻触发缺页——因为栈下方那一页就是它自己的结构体,物理内存都在,写进去完全合法。于是溢出变成“静默污染”:state被改成某个奇怪的值、counter变成负数、信号字段被覆盖。后果是任务行为开始变得不可预测,但没有一个明确的崩溃点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询