☰
手写内核:从内核线程到用户进程的特权级切换与系统调用实战
2026/10/26 14:24:11 网站建设 项目流程

“用户进程”这四个字,我在这个手写内核项目里一直拖到第十八篇才真正动手,原因很实在:前面十七篇把引导、分页、中断、内核线程这些地基铺完之后,只要不碰用户态,整个系统就一直是“所有代码都跑在最高特权级”的裸奔状态,任何一行野指针都能把整机搞挂,调试起来特别难受。实现用户进程不是一个孤立功能,它是一次把特权级隔离、地址空间隔离、栈切换、系统调用、进程调度全串起来的总装活儿。你修好其中任意一环,另外三环的问题会立刻以别的面目冒出来。这篇就按我自己的实现顺序,把“从内核线程到用户进程”这一整条链路拆开讲:为什么这么设计、每一处参数怎么算、代码长什么样,以及我在模拟器里被三重故障反复毒打之后总结出来的排查套路。适合已经能把内核跑起来、有基本分页和中断处理、但一直没敢让 CPU 降权到 ring3 的读者。

1. 动手之前先想清楚:用户进程到底比内核线程多了什么

内核线程和用户进程在调度器眼里长得几乎一样,都是一份寄存器现场加一个栈。真正的差别全在“权力”二字上:内核线程能在 ring0 里随便读写物理内存、随便关中断、随便碰端口,而用户进程这些东西一样都不许碰。所以这一节我想先把设计意图讲透,否则后面写代码时很容易做出“形式上降了权,实际上一点隔离都没有”的假用户态——我第一版就是这么干的,用户程序页表的 U/S 位全填 0,结果一进 ring3 就页错误,白折腾两天。

1.1 隔离的三根支柱:特权级、页表、栈

特权级解决的是“能不能执行某条指令”。x86 的 CPL 存在于 CS 寄存器低两位,ring3 下执行cli、hlt、lgdt、直接写 CR3 这类指令会立刻触发通用保护异常。这一层是硬件强制的,你不需要写任何检查代码,CPU 自己就把门关上了。我在早期版本里曾经天真地想过“在 C 里判断一下当前进程是不是用户进程再决定要不要执行危险操作”,事实证明这种软件检查毫无意义——只要有一条路径漏判,隔离就全线崩塌。

页表解决的是“能不能访问某块内存”。这一层靠页目录项和页表项里的 U/S 位实现,CPL=3 时只能访问 U/S=1 的页。这里有个很多人第一次都会踩的细节:页表的每一级都要置位,页目录项和页表项任意一级的 U/S 为 0,整页对用户态就是不可见的。我第一次只改了页表项忘了页目录项,表现是用户程序一取指就 #PF,错误码 bit2 是 1(用户态访问)、bit0 是 0(保护违规),盯着错误码看了半小时才反应过来。

栈解决的是“切换特权级时现场往哪儿放”。CPU 从 ring3 掉进 ring0 时,绝不会继续用用户栈——那样用户程序就能伪造内核的返回地址。它会去 TSS 里读 SS0 和 ESP0,硬切到一个内核专属的栈上。这是三根支柱里最容易被忽略的一根,因为它平时完全不可见,只有在你忘了设置 TSS 或者 esp0 指向垃圾时,才会以三重故障的形式给你一个黑屏。

注意:这三根支柱是“与”的关系,缺一根都不叫用户进程。只降特权级不隔离页表,用户程序仍然能读写内核数据;只隔离页表不设 TSS,第一次中断进来系统就死。

1.2 这一篇要打通的四个关卡

把目标拆细一点,这一篇实际要干的事情是四件。第一件是改 GDT,加上 DPL=3 的代码段和数据段描述符,再加一个 TSS 描述符。第二件是把用户程序的映像搬进一块独立的虚拟地址区间,并且把这整段映射的 U/S 位全部打开。第三件是构造一个“从中断返回”的假现场,让iret指令把 CPU 从 ring0 带到 ring3,这一步是整个功能的分水岭。第四件是提供一个int 0x80的系统调用入口,让用户进程在需要内核帮忙时能主动回来,干完活再安全地回到用户态。

这四件事有严格的依赖顺序。GDT 没改,iret里的 CS 选择子 DPL 就对不上,直接 #GP;页表没打开 U/S 位,跳过去第一条指令就 #PF;系统调用没做,用户程序跑起来就是个死循环,你还得靠时钟中断强行抢回控制权,体验极差。我建议按这个顺序一个一个验证,每做完一步就用一条专门的测试指令确认它真的生效了,别攒到最后一起调。

2. 特权级切换的硬件底座:TSS 与 GDT 的改造

GDT 是这个系列里最早写的东西,前面一直只放了两三项:空描述符、内核代码段、内核数据段。这次要往里塞三样新东西——用户代码段、用户数据段、TSS。改动不大,但每一项的字段都错不起,尤其是访问权限字节。我第一次写的时候把用户代码段的 DPL 填成 0,选择子却是 0x1B(RPL=3),iret执行瞬间就 #GP,而且因为当时异常处理本身还有 bug,直接进了三重故障重启循环,连打印都看不到。

2.1 段描述符里的 DPL 到底怎么填

标准的扁平模型下,四个段的基址都是 0、限长都是 4GB,唯一区别就是访问权限字节。内核代码段是0x9A(存在、DPL=0、代码段、可读),内核数据段是0x92(存在、DPL=0、数据段、可写)。用户代码段把 DPL 改成 3,就是0xFA;用户数据段同理是0xF2。这些数值不用背,记住结构就行:bit7 存在位,bit6-5 DPL,bit4 描述符类型(1 表示代码或数据段),bit3 可执行位,bit1 可读或可写位。

选择子那边更有讲究。用户代码段的选择子如果选在 GDT 第 4 项(索引 3),就是3<<3 = 0x18,加上 RPL=3 得到0x1B;用户数据段在第 5 项,就是0x20 | 3 = 0x23。这里有个硬性约束:iret弹出 CS 后,CPU 会检查 CS 的 DPL 必须等于 CS 的 RPL,并且和 SS 的 DPL 一致,任何一条不满足都是 #GP。所以0x1B和0x23必须成对出现,写代码时强烈建议用宏定义而不是硬编码数字,否则后面排查时你会在满屏的十六进制里迷失。

选择子索引描述符类型DPLRPL用途
0x081代码段00内核代码
0x102数据段00内核数据
0x1B3代码段33用户代码
0x234数据段33用户数据
0x285TSS00任务状态段

表里这五项就是我在这一篇里 GDT 的最终形态。注意 TSS 描述符的类型码是0x89(32 位可用 TSS,DPL=0),限长至少要覆盖 TSS 结构体大小减一,如果是 104 字节的经典结构就填 103。填错了不会立刻报错,但ltr指令会给你一个 #GP,算是个还算友好的失败点。

2.2 TSS 不是“历史包袱”:esp0 是唯一入口

很多讲操作系统的资料会说“现代系统不再用硬件任务切换,TSS 基本没用了”,这话对,但容易误导人。在 32 位保护模式下,TSS 有一项功能是无法绕开的:中断或异常从低特权级进入高特权级时,SS0 和 ESP0 必须从 TSS 里读。没有任何寄存器或 MSR 能替代它(64 位下才有专门的 MSR)。所以你必须老老实实建一个 TSS,填好 ss0 和 esp0,然后ltr加载它。

我的 TSS 结构体直接用经典布局,重点是两个字段:ss0 = 0x10(内核数据段选择子)和esp0,后者在每次进程切换时都要更新成当前进程内核栈的栈顶。这里有个非常隐蔽的坑:esp0 必须指向内核栈的“最高地址”,不是当前使用位置。因为 CPU 切栈后会立刻往这个位置压东西,如果你填了栈中间某个位置,压栈就会覆盖掉已有的数据。我第一版填的是task->kstack + 4096,感觉是对的,但kstack是个uint32_t*指针,指针加法已经乘了 4,实际偏移是 16384,直接指到栈外面去了。这个 bug 表现为“第一次中断返回后用户程序的行为完全随机”,查了很久才反应过来。

还有一个字段是iomap_base,它决定 I/O 权限位图的起始偏移。如果你根本不打算让用户态碰端口,把它设成 TSS 结构体大小(104)就等于告诉 CPU“位图不存在”,用户态任何in/out指令都会 #GP,正是我们要的效果。要是忘了设这个字段,它会是 0,CPU 会跑去 TSS 开头把 ESP0 之类的数据当位图解析,行为就完全不可预测了。

2.3 内核栈的分配方式与初始化时机

每个用户进程必须独占一个内核栈,大小 4KB 足够我现在的用量(中断现场 + 一层函数调用)。分配方式很朴素:内核的物理内存管理已经能给出连续页了,直接要一页,因为内核页表是线性映射的,物理地址可以直接当虚拟地址用,不需要额外映射。

初始化时机上我踩过一个顺序坑:一开始我在进程创建函数里先把 esp0 写好,再分配内核栈,结果两者对不上。正确顺序是先分配栈、算出栈顶、再写进 TSS 的 esp0(或者写进 PCB,切换时再刷到 TSS)。另外还要注意,进程第一次进入用户态之前,内核栈应该是空的,栈顶上不要留任何“返回地址”。因为 CPU 从 ring3 掉进 ring0 时会自己切换到 esp0 然后压现场,你如果事先在栈里留了东西,返回时就会把它们当成现场的一部分弹出来,现场就乱了。

3. 地址空间搭建:把程序放进 0x08048000

内核线程的代码和数据直接躺在内核镜像里,用户进程不行。它需要一块“内核看不见、用户随便用”的地盘。我的做法是给每个用户进程建一个独立的页目录,把内核空间(前 64MB 左右)直接共享同一份页表,剩下的高地址区间留给用户进程自己用。这样做的理由很直接:共享内核页表意味着中断进来时 CPU 换到内核栈后,取指、取数据全都能正常工作,不需要任何临时的页表切换;而用户区独立保证了两个用户进程之间互不可见。

3.1 页表项里的 U/S 位

前面提过要点,这里说得再细一点。页目录项和页表项的 bit2 是 User/Supervisor,bit1 是 Read/Write。要构造一个用户可访问的页,正确的值应该是0x07(存在 + 可写 + 用户),而不是0x03。我第一次为了省事只写了0x03,页表能建起来,内核访问也没问题,但用户态一跑就 #PF——而且是在很后面才触发,因为你可能先跑了一段只是取指的代码,直到某条写操作才暴露出来。

还有一条容易忽略:PMD/PDE 里的用户位是“下传”的,如果页目录项 U/S=0,即使页表项 U/S=1,整页对用户态依然不可见。这在排查时非常具有迷惑性,因为你在调试器里 dump 出来的页表项看着是对的。我的建议是写一个map_user_page()辅助函数,把两级页表都显式设为0x07,其他位置一律不手写这个常量,从源头杜绝漏改。

标志位值含义用户页必备
P (bit0)0x01页存在是
R/W (bit1)0x02可写用户栈、数据段必备,代码段可省
U/S (bit2)0x04用户可访问是,且页目录与页表都要置
PWT/PCD0x08/0x10缓存策略一般置 0

另外,用户程序页面我是按需分配的,但第一次加载时就把整个映像需要的页全部建好,避免在用户态触发缺页中断时还要去处理“内核态被中断打断又进入缺页处理”的嵌套场景。现阶段不追求完美,先把流程跑通,惰性分配留到后面做内存优化时再说。

3.2 一个够用的 ELF 加载器

用户程序的格式我选了 ELF,理由不是“标准”这种虚的,而是交叉编译工具链天然能产出,不用自己写链接脚本搞裸二进制。但我只实现了最小的加载逻辑:读 ELF 头,检查魔数、机器类型、类型是 ET_EXEC,然后遍历程序头表,对每个 PT_LOAD 段做映射和拷贝。

这里有个必须处理的细节:段的虚拟地址和文件偏移通常不是页对齐的。比如p_vaddr = 0x08048054,p_offset = 0x54,你不能直接把文件内容 memcpy 到地址 0x08048054 之后就说完了,因为前面的 0x54 字节是文件头。我的处理方式是:按p_vaddr & 0xFFFFF000对齐后映射足够的页,然后用p_vaddr + p_filesz计算需要拷贝的目标位置,从文件原始偏移p_offset开始拷贝p_filesz字节,剩下的p_memsz - p_filesz部分清零(这是 .bss)。

代码大致是这样:

static int load_segment(struct process *p, const struct elf_phdr *ph, const uint8_t *file) { uint32_t vaddr = ph->p_vaddr & 0xFFFFF000; uint32_t off = ph->p_vaddr & 0x00000FFF; uint32_t end = ph->p_vaddr + ph->p_memsz; for (uint32_t a = vaddr; a < (end + 0xFFF) & 0xFFFFF000; a += 0x1000) { void *page = alloc_physical_page(); memset(page, 0, 0x1000); if (map_user_page(p->pgdir, a, (uint32_t)page, PAGE_USER | PAGE_WRITE) < 0) return -1; } uint8_t *dst = (uint8_t *)(ph->p_vaddr); /* 内核线性映射,直接可写 */ memcpy(dst, file + ph->p_offset, ph->p_filesz); return 0; }

注意最后那行memcpy:因为内核页表对这段物理内存有线性映射,我可以在内核态直接用虚拟地址写用户页,不需要临时切换到用户页目录。这是共享内核页表带来的一个实用便利。但如果你后面改了内核映射策略,这行代码就得跟着改,所以我在函数里留了注释标明这个依赖。

3.3 用户栈、保护页与参数传递

用户栈我固定放在0xBFFFF000附近,向下增长,分配 8 页,并在最下面那一页放一个不映射的保护区。这个保护页是个经验性的防呆设计:用户程序栈溢出时不会悄悄写坏别的数据,而是直接 #PF,错误地址落在保护区附近,一眼就能看出是栈炸了。我最早没做这个,结果一个递归写错的小程序把相邻的代码页覆盖了,表现是“程序执行到一半指令变了”,调试起来简直要命。

栈顶我预留了经典的参数传递布局:argc、argv指针数组、字符串本体,然后argv[0]之类的指针值本身就是虚拟地址,跟真实进程启动时的样子一致。这一步不是必须的,但我发现保留它能让用户程序的main(int argc, char **argv)直接编译过来而不用改代码,省了很多事。

4. 造一个“假中断现场”,让 CPU 自己跳进 ring3

这是整篇里最核心的一节。前面所有准备工作都是静态的,只有iret这一条指令真正把 CPU 的权力降下去。理解它的关键就一句话:iret相信栈上的内容。你在栈上摆好五个值,它就照着这五个值设置 EIP、CS、EFLAGS、ESP、SS。所以进入用户态不需要任何“特殊指令”,只需要精心伪造一个“看起来像刚从用户态发生中断”的栈帧。

4.1 进程控制块的最小字段集

在写切换代码之前,得先把 PCB 定下来。我这里的字段不多,但每个都有明确用途:

struct process { uint32_t pid; int state; /* RUNNING / READY / ZOMBIE */ uint32_t pgdir; /* 该进程页目录的物理地址 */ uint32_t kstack; /* 内核栈物理地址(线性映射可直接用) */ uint32_t kstack_top; /* 内核栈顶,切换时写入 TSS.esp0 */ struct trap_frame *tf; /* 指向内核栈上保存的现场,用于恢复 */ int exit_code; struct process *next; };

tf这个字段是我后来加的。一开始切换靠汇编直接搬栈指针,简单但难调试;改成“内核栈顶上固定位置放一份 trap_frame,切换时只换栈指针和页目录”之后,任何一个进程的现场都能在内核态被直接读到,gdb 里p *task->tf就能看到完整的寄存器快照,排查效率提升非常明显。

4.2 iret 的栈布局与第一段进入代码

进入用户态的汇编短小但顺序极其严格。iret弹栈的顺序是 EIP、CS、EFLAGS、ESP、SS,所以压栈必须反着来:先压 SS,再压 ESP,再 EFLAGS,再 CS,最后 EIP。

.global user_enter user_enter: mov 4(%esp), %eax /* 用户入口地址 */ mov 8(%esp), %ebx /* 用户栈顶 */ mov $0x23, %cx /* 用户数据段选择子 */ mov %cx, %ds mov %cx, %es mov %cx, %fs mov %cx, %gs push $0x23 /* SS3 */ push %ebx /* ESP */ push $0x202 /* EFLAGS: bit1 固定为 1,IF=1 开中断 */ push $0x1B /* CS,DPL=3 */ push %eax /* EIP */ iret

EFLAGS 里的0x202值得单独说。bit1 是保留位必须为 1,bit9 是 IF 中断标志。很多人第一次写会填0x2,结果进了用户态之后时钟中断被关着,程序一旦死循环整个系统就彻底卡住,连调试输出都没有。我第一次就是这么翻车的,屏幕上最后一行日志停在“switching to user”,之后再无任何反应。后来把 IF 打开,立刻就能看到时钟中断正常打进来,问题迎刃而解。

还有一个顺序上的陷阱:段寄存器必须在压栈之前改好。如果你先压栈再改 ds,中间那段窗口里若发生任何异常,CPU 会用旧的段寄存器去处理,可能直接三重故障。更稳妥的做法是这几条mov全部做完再做压栈动作,并且用cli/sti把整个过程包起来。

4.3 时钟中断接管:从用户态回到内核的正确姿势

用户程序一旦跑起来,正常情况下它不会自己回来(除非调用了exit)。真正把控制权交回内核的是时钟中断。CPU 收到中断时做这几件事:从 TSS 读 SS0 和 ESP0 切换栈、压入 SS3/ESP/EFLAGS/CS/EIP、跳到 IDT 里注册的处理函数。所以中断入口代码第一件事是把其余段寄存器也保存下来,并且立刻把它们置成内核数据段选择子:

isr_common: push %ds push %es push %fs push %gs mov $0x10, %ax mov %ax, %ds mov %ax, %es mov %ax, %fs mov %ax, %gs push %esp /* 传给 C 的 trap_frame 指针 */ call interrupt_dispatch add $4, %esp pop %gs pop %fs pop %es pop %ds add $8, %esp /* 丢弃 int_no 和 err_code */ iret

这里push %esp是关键,它把当前栈指针传给 C 函数,C 里把这个指针存进current->tf,之后不论调度到哪个进程,都能靠这个指针恢复现场。注意int_no和err_code的处理位置——如果异常本身压了错误码、你又手动 push 了一个,出栈时就要多弹一个,这一点在写中断桩函数的时候容易数错,导致iret弹出来的是乱值。

4.4 上下文切换与 CR3 的配合

换进程的时候要做两件事:换栈指针、换页目录。顺序上我建议先换 CR3 再换栈,因为栈本身也是虚拟地址,必须保证新页目录里这个地址是有效的。不过因为内核空间所有进程共享同一份页表,所以严格来说内核栈的映射是相同的,先换哪个都不会崩,这一点算是共享内核映射的一个额外好处。

.global switch_to switch_to: /* switch_to(struct process *prev, *next) */ push %ebp push %ebx push %esi push %edi mov 20(%esp), %eax mov %esp, 24(%eax) /* prev->saved_esp = esp */ mov 24(%esp), %eax mov 24(%eax), %esp /* esp = next->saved_esp */ mov 28(%eax), %eax mov %eax, %cr3 /* 切换地址空间 */ pop %edi pop %esi pop %ebx pop %ebp ret

同时别忘了把next->kstack_top写进 TSS 的 esp0,否则下一次从用户态中断进来,CPU 会切到上一个进程的内核栈上去,两个进程的现场互相踩,症状是“跑着跑着寄存器值变成了另一个程序的东西”,极具欺骗性。

5. 系统调用:用户进程伸向内核的唯一那根手指

用户进程被关在笼子里,想打印一个字符都得求内核。这个“求”的通道就是系统调用。我的实现选了int 0x80而不是sysenter,理由是它跟普通中断走同一条路径,复用现成的保存现场、分发、返回逻辑,改动量最小;性能上差一点,但在这个阶段完全不是瓶颈。

5.1 门的类型选择与 DPL 设置

IDT 里第 0x80 项必须是一个 DPL=3 的中断门(类型 0xE),不是陷阱门也不是任务门。中断门的特点是进入时自动清 IF,也就是关中断,这样内核在处理系统调用的过程中不会被时钟打断,避免了重入的一堆麻烦事;返回时iret会恢复原来的 EFLAGS,用户态的 IF=1 自然就恢复了。陷阱门不清 IF,用在这里会引入不必要的并发问题。

DPL 必须设成 3,否则用户态执行int 0x80直接 #GP。这一点跟其他异常处理项(DPL=0)不同,写 IDT 初始化代码时容易顺手全填 0,然后系统调用一直失败。我第一次就是这么写的,用户程序第一条write直接报 #GP,err_code 是0x80相关,看了半天 IDT 表才发现问题。

5.2 参数传递约定与系统调用表

寄存器约定我用最经典的一套:eax放系统调用号,ebx、ecx、edx、esi、edi依次放参数,返回值放回eax。这套约定简单直接,用户态一行内联汇编就能发起调用:

static inline int syscall3(int num, int a, int b, int c) { int ret; __asm__ volatile ("int $0x80" : "=a"(ret) : "a"(num), "b"(a), "c"(b), "d"(c) : "memory"); return ret; }

分发逻辑就是一张函数指针数组,索引是调用号。我给每个入口函数加上统一的(int, int, int)签名,多余参数忽略,不够的补 0。这样做的好处是不用写一堆 switch 分支,加新调用就是往数组里塞一项。

调用号名称参数返回值
1sys_exitcode不返回
2sys_writefd, buf, len实际写出的字节数
3sys_getpid无当前 pid
4sys_brknew_break新的堆顶地址
5sys_yield无0

5.3 sys_write 与 sys_exit 的实际实现

sys_write目前只支持 fd=1(标准输出),直接往内核维护的文本缓冲里写,再由定时刷新到屏幕上。sys_exit稍微麻烦一点,它不能返回,必须直接把当前进程标记为僵尸、从就绪队列摘掉,然后触发一次调度切走。实现上我是把它做成“修改现场”:把tf->eip指向一段内核里的收尾代码,等iret的时候自然就跳到那里去,而不是当场switch_to。

static int sys_exit(int code) { struct process *p = current; p->state = PROC_ZOMBIE; p->exit_code = code; p->tf->eip = (uint32_t)process_exit_stub; return 0; }

这种“延迟退出”的写法在调试阶段特别友好,因为你在process_exit_stub里可以打一行日志,确认进程确实是走正常路径退出的,而不是掉进某个异常里被误判。

5.4 内核不能无条件信任用户指针

这是我最想强调的一条。sys_write(1, buf, len)里的buf是用户给的,可能是任意值,包括指向内核空间的地址。如果你在内核里直接memcpy或者逐字节读,用户程序就能通过这种方式把内核数据读出来打印——隔离就白做了。

我的做法是加一个user_range_ok(ptr, len)检查:先把 ptr 和 ptr+len 都按 4KB 向下/向上取整,然后遍历涉及的每一个页,检查该页在当前进程页目录里存在、且 U/S 位为 1。任何一页不满足就直接返回 -1(对应 EFAULT)。这个检查本身不慢,因为一次系统调用的数据量都很小,多走几步页表遍历完全无感。

提示:检查完到真正读取之间理论上有 TOCTOU 窗口,但在这个单核、系统调用期间关中断的场景下不存在并发修改,所以可以放心。等以后做了多核或者允许内核态被抢占,这里就得改成先拷贝到内核缓冲区再校验。

6. 调试实录:从三重故障到用户程序跑起来

前面几节讲的都是“正确的样子”,但真实开发过程里我改坏的次数远多于改对。这一节把几个最有代表性的故障复盘一下,包括症状、我怎么定位、根因是什么。这些坑我基本每个都踩过至少一次,有些还踩了两次。

6.1 症状、定位与修复过程

故障一:一执行iret就重启。现象是屏幕日志打到“entering ring3”然后整个模拟器复位,没有任何异常打印。三重故障的典型特征就是没有输出,因为它连异常处理都跑不起来了。定位方法是把 gdb 挂上去,在user_enter的iret那行下断点,单步执行,看是哪条指令触发的。结果发现iret直接变成了复位。进一步检查栈内容,发现压的顺序搞反了——我先压了 EIP 再压 CS,iret把 CS 的值当成了 EIP,跳到了 0x1B 这个地址,那里根本没映射,接着又触发异常,异常处理又出错,最终三重故障。修复就是把压栈顺序倒过来。

故障二:进了用户态,第一条指令就页错误。错误码是0x05(存在、用户态访问、读),地址正是程序入口。这说明页在那,但用户态无权访问。回去看映射代码,map_user_page里页目录项我传的是PAGE_PRESENT | PAGE_WRITE,漏了PAGE_USER。补上之后立刻正常。

故障三:用户程序能跑,但系统整体卡死。现象是用户程序开始执行后,所有中断都不响应了,屏幕上的光标不动。这就是前面说的 EFLAGS 里没开 IF 的问题,iret用的0x002而不是0x202。改完之后时钟中断恢复,能正常调度。

故障四:第二次系统调用必然崩。第一次write正常,第二次进去出来就乱。检查发现中断处理入口在返回时没有恢复ds/es/fs/gs,导致回到用户态时数据段还是内核的 0x10,用户程序访问自己的全局变量会 #GP。补上pop %gs/%fs/%es/%ds就好了。这个 bug 之所以第一次不暴露,是因为第一次系统调用返回后用户程序紧接着就跑到了exit,没访问数据段。

6.2 常见故障速查表

现象最可能原因快速验证
iret 后立刻复位CS/SS 选择子 DPL 与 RPL 不匹配,或压栈顺序错gdb 在 iret 处查栈内容
进用户态立刻 #PF页表缺 U/S 位,或只改了一级打印页目录项和页表项低 12 位
用户程序跑飞无响应EFLAGS 未开 IF,或 TSS.esp0 指向错误检查 0x202 和高地址栈顶
系统调用 #GPIDT 项 DPL 不为 3,或用了陷阱门检查 IDT[0x80] 低字节
第二次中断崩段寄存器未恢复,或 err_code 出栈数量错单步中断返回路径
多进程互相串数据TSS.esp0 没跟着进程切换更新每次切换后打印 esp0
栈溢出无声无息没有保护页加一页不映射的守卫页

6.3 我的调试工具组合

现在这套流程基本固定了:先开模拟器的调试端口,用 gdb 连上去,用info registers看 CPL(CS 低两位)、用x/8wx $esp看栈上的现场、用monitor info registers交叉验证。异常处理里我加了打印 EIP、错误码、CR2 的逻辑,虽然屏幕输出简陋,但比没有任何信息强太多。

另外一个小技巧:我给进程切换加了可选日志,只在编译时打开DEBUG_SWITCH宏才输出,每次切换打印“pid_from -> pid_to”。这个日志在排查“现场被踩”类问题时价值极高,因为你能清楚看到是哪一次切换之后行为开始异常。以前我靠满屏 printf 硬猜,加上这个之后定位时间从几小时缩短到几分钟。

还有一个习惯值得推荐:给用户程序写一个最小的测试程序,只做三件事——打印一个字符、调用getpid、调用exit。先保证这个能跑通,再上复杂程序。我一开始直接拿一个循环打印的程序去测,结果因为系统调用有 bug,第一次调用就崩,根本分不清是加载错了还是调用错了。把测试粒度降下来,是省时间不是费时间。

写到这里,用户进程这条链路基本通了。回顾下来最深的体会是:真正难的不是某一行代码,而是理解 CPU 在特权级切换时替你做了哪些事、又从哪儿读参数。TSS 的 esp0、页表的 U/S 位、iret的栈布局这三处,只要有一处理解偏了,现象就会以完全不相干的样子出现,逼着你从现象倒推。我现在习惯在改任何涉及特权级的代码前,先在纸上把栈的最终形态画出来,标清楚每个位置是谁压的、谁弹的,这个动作至少帮我省掉了一半的调试时间。

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

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

立即咨询