ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

操作系统实验:内核栈切换与进程上下文切换机制解析

操作系统实验:内核栈切换与进程上下文切换机制解析

实验做到第四个,很多人才第一次真切感受到"栈"是个物理上存在的东西。前面几次实验里,进程表、GDT、LDT 都老老实实躺在内存里,你改一个字段、跑一遍,结果对得上就算过;到了内核栈的切换这一关,程序不再给你报错,它只会安静地跑飞——屏幕一片空白,调试器里 esp 指到一个谁也不认识的地方,或者干脆重启。这篇笔记围绕操作系统实验中内核栈切换这个主题展开,把两套看起来相似、实际完全独立的换栈机制拆开讲清楚:一套是 CPU 在特权级抬升时自动完成的换栈,另一套是调度器把 CPU 交给另一个进程时发生的整体上下文切换。搞不清这两者的边界,实验基本没法往下做;搞清了,后面再处理进程间通信、信号之类的内容会顺很多。下面全部是我在这个实验里反复调试之后整理出来的东西,包括几个必踩的坑和对应的自查方法。

1. 两张栈并行存在:内核栈与用户栈各管一段地址

1.1 一页页框里同时装着进程控制块和栈空间

先把这个实验里最反直觉的一个设计说清楚。一个进程的内核栈不是单独分配的一块内存,它和进程控制块挤在同一个物理页框里。核心结构长这样:

union task_union { struct task_struct task; /* 进程控制块,从页起始地址往上放 */ char stack[PAGE_SIZE]; /* 同一块页,解释成 4KB 的栈 */ };

task_union用 union 而不是 struct,意思是这两样东西共用同一块 4KB 空间,不是各占一份。进程控制块从页的低地址开始往上长,而栈顶固定在页的高地址,压栈时 esp 递减,往下长。两边正好朝着对方长,只要进程控制块别太长、栈别太深,中间那段空闲区就是安全的缓冲区。

这样做的好处很直接:定位当前进程几乎不需要成本。拿到当前 esp,把低 12 位抹掉,得到的就是这个页框的起始地址,也就是task_struct的地址:

struct task_struct *p = (struct task_struct *)(esp & 0xfffff000);

在一部分实现里,内核就是用这个技巧快速找到当前任务结构的,省掉了一次查表。代价是这 4KB 必须页对齐,一旦对齐没做好,这个"抹低位"的动作就会指向一个完全不相关的页,后面的字段全部读错,而且不会报错,只会表现为各种莫名其妙的异常。

栈顶的地址可以直接算出来:

p->tss.esp0 = PAGE_SIZE + (long)p; /* 页起始 + 4KB = 页尾 */

减一是为了对齐还是不减,不同实现里有细微差别,但方向一致:内核栈从页尾往下用。

1.2 用户栈为什么不能直接交给内核使用

有人第一次接触时会问:用户态已经在用栈了,进内核接着用不就行了,为什么还要单独搞一块?原因不在于"想不想",而在于"敢不敢"。

用户栈的地址是用户程序能算出来、也能改出来的。用户态代码随手写一个越界的数组下标,就能把栈上的返回地址、局部变量覆盖掉。如果内核复用这块栈,那么内核执行过程中压进去的返回地址、保存的寄存器现场,同样处在这片可被用户代码踩踏的区域内。一个精心构造的用户程序就能让内核跳到自己想要的地方去执行。

除此之外还有空间上的问题。用户栈在用户地址空间里,而内核需要访问的很多数据结构——比如进程表、缓冲区、页表——都在内核空间。如果内核栈放在用户空间,内核在压栈时一旦触发缺页,处理缺页的代码又要压栈,就可能形成递归。独立的内核栈配上内核空间的页表映射,天然避开了这一类嵌套问题。

所以结论是硬的:每个进程一个内核栈,与用户栈物理隔离、地址隔离、权限隔离。切到内核态就一定换栈,没有例外,这也正是本实验的出发点。

1.3 两块栈在线性地址空间里的位置关系

在 4GB 线性地址空间的经典布局下,用户栈通常被放在用户空间顶端附近,比如 3GB 边界下方一点点,由 LDT 里的栈段描述符配合 esp 定位;而内核栈位于内核数据段里,基址为 0,所以它的地址就是物理地址,页框号由get_free_page()分配。

这两块栈的地址没有重叠的可能,连接它们的唯一纽带是进程的 TSS:从用户态进内核时用 TSS 里的ss0:esp0,从内核态回用户态时用内核栈上保存的ss:esp。整个实验里,你调试的绝大部分问题最终都能归结到这条链路的某一环没接好。

一个实用的自查习惯:在切换前后把两个 esp 都打印出来,内核栈地址应该落在内核数据段对应的物理范围内,一般是 1MB 以上;用户栈地址应该接近 3GB 顶端。如果某次打印发现内核栈 esp 跑到了几 GB 的位置,基本可以断定是从用户态进内核时栈没换成功。

2. 特权级抬升触发的那次换栈:TSS 里的 ss0 与 esp0

2.1 int 0x80 进门瞬间硬件按顺序做的三件事

从用户态陷入内核态,是内核栈第一次登场的地方。用户代码执行int $0x80时,CPU 会去中断描述符表里取第 0x80 项。这一项在初始化时必须被设置为中断门或陷阱门,并且 DPL 设为 3,否则用户态根本无法触发。门描述符里记录了内核代码段选择子和入口地址,也就是system_call的地址。

确认特权级要从 3 升到 0 之后,CPU 做的动作非常机械,顺序固定:

  1. 从当前 TR 寄存器指向的 TSS 里读出ss0和esp0;
  2. 把原来的用户栈 SS 和 ESP 压入这个新栈(即内核栈);
  3. 依次压入 EFLAGS、CS(用户代码段选择子)、EIP(返回地址);
  4. 把 CS 和 EIP 换成门描述符里的内核段选择子和入口地址,SS 和 ESP 换成刚读出来的ss0:esp0。

这个过程之后,esp 已经指向内核栈,内核代码才开始执行。所以system_call入口处的 esp 是内核栈的地址,不是用户栈的。这一点在写汇编入口时要牢记,否则后面一压栈,压到的可能就是你不想动的地方。

进入system_call之后,还有一层操作:把各个通用寄存器挨个压栈,形成完整的现场。这就又占了内核栈上的一批空间。一个系统调用走下来,内核栈的消耗是可以数出来的:硬件压的五个字、手工压的若干个寄存器、C 函数调用的返回地址和局部变量。

2.2 esp0 的写错时机与失效后果

tss.esp0必须始终指向"下一次从用户态进入内核时要用到的栈顶"。它并不是一个静态值,思考一下它在什么时候需要更新:

  • 新进程创建时,必须设置一次,设成PAGE_SIZE + (long)p;
  • 进程在内核态运行期间,如果发生了嵌套中断(内核态到内核态),此时不会读 TSS,所以不受影响;
  • 如果某些实现允许修改内核栈的位置(比如把任务结构挪到别处),那就要同步改。

这里有个非常容易忽略的点:从用户态进入内核时,CPU 每次都会去读tss.esp0,而不是"上一次停在哪儿就从哪儿接着用"。这意味着如果进程在内核态把栈用深了、退出时正常恢复,那没问题;但如果你在某个系统调用里手动改了当前任务 TSS 的esp0而没有还原,下一次从用户态进来就会从一个错误的位置开始压栈。

我踩过一次很典型的坑:为了调试,在一处系统调用里临时把esp0打了个日志并试图改一下,忘记还原。之后的表现是:用户程序运行几步后,某个系统调用的返回地址被覆盖,程序跳转到一个完全无关的位置,随后崩溃。由于崩溃点离出错点很远,一开始完全查不到方向,最后靠在内核栈入口打条件断点、每次进内核校验 esp 是否等于tss.esp0才定位到。

ss0一般是内核数据段选择子,在经典实现里是 0x10。写错它后果比写错esp0更严重,因为段基址错了,整个栈的物理位置就全错了。表现通常是第一次int 0x80就直接触发异常,而且连异常处理都没法正常进行,直接重启。

2.3 iret 出门时栈是怎么退回去的

从内核态返回用户态用iret。它和进门的过程互为逆操作:

  1. 从当前栈(此时是内核栈)弹出 EIP、CS、EFLAGS;
  2. 检测到 CS 的 RPL 是 3,说明要回到低特权级,于是继续弹出 ESP、SS;
  3. 用弹出来的值恢复用户栈。

这里的关键是:用户栈的 ESP 和 SS 是被保存在内核栈栈顶附近的。也就是说,系统调用过程中,用户栈指针只是被"复制"到了内核栈上,用户栈本身的内容并没有动。

这解释了一个常被误解的现象:内核里访问用户空间数据时,为什么要用get_fs、put_fs之类的函数,或者临时切换 fs。因为内核当前的段寄存器状态是内核数据段,直接解引用一个用户指针,拿到的不是用户的数据。而在地址空间布局上,用户数据确实存在,只是需要借道用户数据段选择子去访问。

还有一个细节值得注意:iret弹栈的顺序是可以被利用的。如果你在压栈的时候漏了或多压了一个字,iret出来的值就会整体错位,表现是返回到一个奇怪的地址。调试这类问题时,把内核栈顶那几个字按 EIP、CS、EFLAGS、ESP、SS 的顺序打出来对着看,基本一眼就能发现错位。

3. 调度器把 CPU 交给别人:ljmp 这个动作到底做了什么

3.1 上下文保存为什么不手写 push

上一节讲的是特权级变化的换栈,这一节是完全不同的另一件事:进程 A 被换下、进程 B 被换上时,两台机器各自的内核栈怎么衔接。

如果手写保存上下文,大致要这样:把 eax、ebx、ecx、edx、esi、edi、ebp、esp 逐个 push 到当前进程的内核栈上,记下这个栈顶指针存进进程表,然后从下一个进程的进程表里取出它上次保存的栈顶指针,把它赋给 esp,最后弹出各个寄存器。这段逻辑本身不长,但有几个问题:

  • 段寄存器也要保存和恢复,包括 cs、ds、es、fs、gs、ss,漏一个就会让新进程读到别人的段;
  • 恢复段寄存器必须在恢复 esp 之前完成,否则一旦换栈,原来的栈就找不到了;
  • 汇编里做这些操作,稍有疏忽就错。

更省事的办法是让 CPU 的硬件任务切换机制代劳。x86 提供了一条路径:通过ljmp到一个 TSS 段选择子,CPU 会自动把当前所有寄存器写进当前 TSS,再从目标 TSS 里读出新值装进寄存器。整个保存与恢复是一体的,不会漏项,也不存在先换栈还是先换段的顺序问题。

经典实现中的这段宏大致是:

#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" \ "1:" \ ::"m" (*&__tmp.a), "m" (*&__tmp.b), \ "d" (_TSS(n)), "c" ((long) task[n])); \ }

_TSS(n)是由进程号算出来的 TSS 选择子,task[n]是目标进程的进程结构指针。__tmp这个结构体在告诉编译器:这里需要一个 48 位的远指针操作数(16 位选择子加 32 位偏移),所以用两个 long 拼出来。这个写法看着奇怪,但它是迫不得已的——内联汇编里没法直接给ljmp一个"选择子"操作数。

3.2 任务切换的六个步骤与内核栈指针的隐含保存

ljmp一旦执行,CPU 的动作顺序是这样的:

  1. 用选择子去 GDT 里取出描述符,检查类型确实是 TSS(类型码 9);
  2. 做特权级检查,确认当前 CPL 和选择子的 RPL 都满足要求;
  3. 把当前所有寄存器写入当前 TR 指向的那个 TSS,包括 ESP、EIP、EFLAGS、全部通用寄存器和段寄存器;
  4. 把目标 TSS 里的所有字段读出来装进寄存器,其中包括来自 TSS 的 ldt 字段的 LDTR;
  5. 清掉旧 TSS 描述符的 busy 位,置上新 TSS 描述符的 busy 位;
  6. 把 TR 指向新的 TSS。

第 3 步和第 4 步里就包含了栈指针。也就是说,内核栈的切换在这里是自动完成的——当前进程的 ESP 被存进它的 TSS,新进程的 ESP 从它的 TSS 里取出。你不需要单独写一句"换栈",它就是硬件保存恢复寄存器这一整套动作的副产品。

这里有个必须想清楚的问题:被保存进 TSS 的 ESP,到底是用户栈还是内核栈?答案是:取决于切换发生时进程处于什么状态。由于调度只在内核态发生(要么是时钟中断处理里,要么是系统调用主动让出),所以进程被换下时一定在内核态,ESP 一定是内核栈的指针。这一点是整个实验立得住的前提。

如果某个实现允许在用户态直接触发切换,那 TSS 里存的 ESP 就变成用户栈指针,下一次恢复时内核代码就拿着一把用户栈的指针在跑,后果可想而知。所以调度必须严格限定在内核态里执行,这不是风格问题,是正确性问题。

3.3 ljmp 之后的那条指令为什么能接着跑

新手最常问的一个问题是:进程 A 执行switch_to(B),ljmp之后 CPU 跳去执行 B 了,那 A 下次被调度回来的时候,从哪儿继续?

答案在 TSS 的 EIP 字段里。第 3 步保存寄存器时,EIP 的值是ljmp后面那条指令的地址。所以 A 的 TSS 里记下了这个返回点。等若干轮调度之后,A 再次被选中,ljmp到 A 的 TSS,CPU 读出的 EIP 就是这个返回点,于是 A 从ljmp之后的那条指令继续执行。

但此时 A 用的栈已经不是它离开时的那个栈了吗?不,恰恰是同一个。CPU 从 A 的 TSS 里读出的 ESP,正是当初保存进去的那个内核栈指针。栈上的一切——函数返回地址、局部变量、压进去的寄存器——都还在原地。这就是"内核栈切换"最直观的图景:每个进程都有一个属于自己的栈快照,切换不过是把 ESP 换成另一个快照的栈顶。

有个细节容易忽略:A 被换下的时候,它正处在switch_to这个宏内部,而这个宏是被一个 C 函数(比如schedule)调用的。所以 A 的内核栈上,除了寄存器现场,还有 C 函数层层调用的返回地址。当 A 恢复执行并最终从switch_to返回,它会一路沿栈弹出,回到调用schedule的地方。整条调用链完好无损。这也是为什么硬件任务切换方案虽然看起来"重",却比手写 push/pop 更不容易错。

再补一个实验里常遇到的现象:如果_TSS(n)算错了,ljmp到了一个无效或者类型不对的描述符上,CPU 会抛出通用保护异常。而这个异常的处理程序本身也要执行,此时 TR 可能处于一个不一致的状态,结果往往是二次异常、三次异常,最后直接重启。所以在写_TSS宏的时候,把下标算一遍、用调试器把 GDT 打出来对照,比事后猜要省时间得多。

4. 实验里最容易翻车的几处:从 TSS 描述符到栈溢出

4.1 GDT 中 TSS 描述符的类型位、busy 位与 limit

GDT 里的 TSS 描述符有一个很容易写错的地方:limit。TSS 结构在 32 位模式下至少需要容纳 104 字节(0x68),所以 limit 至少要设成 0x67。如果 limit 设小了,ljmp切换时 CPU 写到 TSS 后半段就会越界,触发异常。有的同学直接照抄一份描述符,结果 limit 是 0,表现出来就是第一次进程切换就崩。

类型码也要对。可用的 32 位 TSS 描述符类型是 0x9,加上 S=0、DPL=0、P=1 之后,常见的字节组合是 0x89。如果手误写成 0x8B,那描述符一放进去就被标成 busy 状态,ljmp到它会被拒绝。同理,任务切换完成后该 TSS 描述符的 busy 位由硬件置位,你如果不理解这一点,看到 GDT 内容"自己变了"会以为是内存被人踩了,白折腾半天。

还有一个初始化顺序上的坑:TR 寄存器必须通过ltr指令加载一次,指向第一个任务的 TSS。如果不加载,TR 里是 0,一旦有中断从用户态进来,CPU 需要从 TR 指向的 TSS 读ss0:esp0,读到的是错误内容,同样直接触发异常。而且这种故障发生在极早期,连打印都来不及。

自查办法很简单,在调试器里敲info gdt,把 TSS 项的基址、limit、类型、busy 位逐项对一遍;再敲info tss,看看 esp0、ss0、ldt 的值是否已经就位。

4.2 页对齐与结构体大小:一个字节的偏差都会让定位失效

前面提到用esp & 0xfffff000定位当前任务结构,这个方法成立的前提是进程结构所在的那块内存必须页对齐。如果task_union数组不是按 4096 对齐分配的,那么抹掉低 12 位之后拿到的可能是数组起始页,而不是当前进程那一页,从而指向别人的进程结构。

确保对齐的方式是在定义数组时显式声明:

union task_union init_task = {INIT_TASK, }; union task_union *task[NR_TASKS] = {&init_task, };

如果是自己管理的内存,记得在分配时用页对齐的接口,或者手工向上取整到 4096 的倍数。

另一个更隐蔽的问题是task_struct本身的膨胀。这个结构里塞了 TSS、LDT、文件表指针、信号相关字段、时间统计等等,写实验时如果往里加调试字段,很容易让它超过预期大小。一旦sizeof(struct task_struct)大到和栈顶开始重叠,内核栈压栈就会直接覆盖进程控制块的尾部字段。这种破坏是静默的:被覆盖的字段可能是某个用不到的成员,程序照跑;也可能是tss.esp0,那么下一次进内核就炸。

我建议在实验里加一行自检,启动时打印一次:

printk("task_struct size = %d, stack top = %d\n", sizeof(struct task_struct), PAGE_SIZE);

确认结构体大小距离 4096 还有足够余量。一般留出 1KB 以上的余量比较安心,因为内核栈在深层调用里消耗得比想象中快。

4.3 内核栈溢出没有保护,踩了只会表现成玄学

内核栈只有 4KB,这个数字看着还行,实际很紧张。一次系统调用进来,硬件压 5 个字,入口汇编压一批寄存器,然后 C 函数一层层调用,每一层都有自己的栈帧。如果中间某个函数在栈上开了个大数组,比如char buf[2048],再加上嵌套调用,几层下来就顶到页头了。

栈溢出没有任何硬件检测。压到页头以下,就进入了task_struct的尾部。写坏什么字段全看运气:

  • 写坏了 LDT 相关字段,用户程序下次访问用户段的某个段就出错;
  • 写坏了 TSS 的 esp0,下次从用户态进内核时栈指针错位;
  • 写坏了信号处理的掩码,进程莫名其妙被杀掉;
  • 写坏了定时器字段,进程再也不被调度。

这一类的症状有个共同点:出错点和真正的破坏点之间隔了很远,而且是概率性的,加个打印就可能不复现。我在这上面花过整整一个晚上,最后是靠在内核栈底部放一个魔数才定位到的:

/* 初始化时在栈底附近填魔数,退出内核前检查是否被改 */ #define STACK_GUARD 0x5A5A5A5A *(unsigned long *)((long)p + sizeof(struct task_struct)) = STACK_GUARD;

这里只是示意思路,实际实现要注意别把魔数放在会被正常使用的区域。每次从内核返回用户态前检查一次,一旦发现被改写,就知道刚才那次调用栈溢出了。再配合把可疑函数的局部数组改小、或者改成静态分配,基本能锁定位置。

顺带说一句,这个实验里最容易被忽视的空间消耗是printk的格式化缓冲。有的实现里它在栈上开了一个不小的缓冲区,如果在深层调用里打日志,很容易成为压垮栈的最后一根稻草。调试期间疯狂打日志反而让程序更容易崩,这个体验相当魔幻。

4.4 汇编约束与编译器优化带来的偶发问题

switch_to那段内联汇编的约束写法,直接决定了生成代码的正确性。那两个"m"约束绑定的是__tmp.a和__tmp.b,如果写成了"r"(寄存器),ljmp的操作数就不是一个合法的内存远指针,编译可能会失败,也可能生成语义完全不同的指令。

另一个隐藏的雷是编译器把switch_to内联展开之后的代码位置。因为宏里用ljmp,之后的代码在编译器看来"可能不会顺序执行",但如果它做了某些跨语句的优化,比如把ljmp后面的变量读取提到前面,行为就可能和预期不一致。稳妥的做法是在宏里显式加上内存破坏提示,或者干脆把switch_to用__asm__ __volatile__包住。

还有_current这个全局变量的更新时机。宏里用xchgl原子地把_current换成新任务指针,这个顺序不能变:必须在ljmp之前完成,否则一旦切走,_current就指向了错误的进程,而新进程恢复执行时拿到的_current是旧值,整个调度逻辑就乱了套。

5. 用调试器把两次换栈看清:断点位置与现象对照

5.1 三个必须打上的断点

第一个断点打在system_call入口。停在这里,看esp是否落在内核栈范围内,看栈顶那五个字是否依次是用户 SS、用户 ESP、EFLAGS、用户 CS、用户 EIP,顺序错了说明门的类型或者压栈逻辑有问题。

第二个断点打在switch_to里的ljmp之前。此时应该能看到_current指向当前进程,目标选择子和目标进程号对得上。单步执行ljmp,观察info registers里 CS、EIP、ESP 三个值的跳变。如果 EIP 跳到一个很奇怪的地址,而 ESP 也没变,那多半是 TSS 里保存的 EIP 不对,回头查上一次切走时是不是在正确的位置。

第三个断点打在新进程恢复执行之后。此时_current应该已经指向新进程,ESP 应该等于新进程 TSS 的 esp0(注意是 TSS 里保存的那个 ESP,不是 esp0 字段)。如果不等,说明 TSS 的保存和恢复过程中有字段被误改了。

三个断点串起来,一次完整的进程切换就能被完整地"看见"。这比读十遍代码都管用。

5.2 从用户态到内核态再到另一个进程的完整观察链

把一次涉及阻塞的系统调用走一遍,观察链条是这样的:

阶段ESP 位置关键动作观察点
用户态运行用户栈,接近 3GB 顶端正常执行info registers里的 SS 应为用户数据段
执行 int 0x80切换到内核栈从 TSS 读 ss0、esp0SS 变为内核数据段,ESP 应在内核区
进入 system_call内核栈,esp 已下降压入各通用寄存器栈顶内容依次可读出
调用 schedule内核栈继续下降选择下一个进程_current尚未变化
执行 ljmpESP 换成新进程的栈硬件保存恢复全部寄存器单步观察 EIP、ESP 同时跳变
新进程恢复新进程上次的内核栈从 ljmp 后继续_current已更新
iret 返回从内核栈弹回用户栈恢复用户态现场SS 变回用户数据段

这张表建议在调试时对着看,任何一格对不上,就锁定那一格查。

5.3 现象与原因对照表

现象可能原因验证方法
第一次进程切换就重启TSS 描述符 limit 为 0 或类型错误info gdt查该项
用户程序执行 int 0x80 立即异常esp0 或 ss0 未初始化info tss查字段值
切换后执行到乱码地址TSS 中保存的 EIP 不正确单步ljmp前后对比 EIP
运行一段时间后随机崩溃内核栈溢出覆盖 task_struct栈底放魔数检查
新进程跑起来但数据错乱页对齐没做好,定位到错误进程结构打印 esp 高位与 task 数组比对
GDT 内容"自己变了"任务切换时 busy 位被硬件更新正常现象,不必处理
系统调用返回值错乱压栈和弹栈数量不一致检查入口汇编与 iret 之间的配对

这张表不是万能的,但它覆盖了我在实验里实际撞上的绝大多数情况。遇到新问题就往里加一行,几次之后你就有自己的排错手册了。

最后分享一个我个人在实际操作中的体会:这个实验最有效率的推进方式,不是先写代码再调试,而是先把两块栈的地址算清楚、把 TSS 的每个字段该填什么列成一张纸,然后再动手。栈切换的代码总量其实很小,难的是脑子里那张内存图要清晰——哪个时刻 esp 指在哪、栈上压了什么、下一次谁来读它。这张图理顺了,写代码是一小时的事;图理顺不了,调试一个星期也可能原地打转。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进