资讯动态

手写内核:从内核线程到用户进程的特权级切换与系统调用实战

发布时间:2026/10/26 14:24:18 来源:尧图企业网站定制
“用户进程”这四个字我在这个手写内核项目里一直拖到第十八篇才真正动手原因很实在前面十七篇把引导、分页、中断、内核线程这些地基铺完之后只要不碰用户态整个系统就一直是“所有代码都跑在最高特权级”的裸奔状态任何一行野指针都能把整机搞挂调试起来特别难受。实现用户进程不是一个孤立功能它是一次把特权级隔离、地址空间隔离、栈切换、系统调用、进程调度全串起来的总装活儿。你修好其中任意一环另外三环的问题会立刻以别的面目冒出来。这篇就按我自己的实现顺序把“从内核线程到用户进程”这一整条链路拆开讲为什么这么设计、每一处参数怎么算、代码长什么样以及我在模拟器里被三重故障反复毒打之后总结出来的排查套路。适合已经能把内核跑起来、有基本分页和中断处理、但一直没敢让 CPU 降权到 ring3 的读者。1. 动手之前先想清楚用户进程到底比内核线程多了什么内核线程和用户进程在调度器眼里长得几乎一样都是一份寄存器现场加一个栈。真正的差别全在“权力”二字上内核线程能在 ring0 里随便读写物理内存、随便关中断、随便碰端口而用户进程这些东西一样都不许碰。所以这一节我想先把设计意图讲透否则后面写代码时很容易做出“形式上降了权实际上一点隔离都没有”的假用户态——我第一版就是这么干的用户程序页表的 U/S 位全填 0结果一进 ring3 就页错误白折腾两天。1.1 隔离的三根支柱特权级、页表、栈特权级解决的是“能不能执行某条指令”。x86 的 CPL 存在于 CS 寄存器低两位ring3 下执行cli、hlt、lgdt、直接写 CR3 这类指令会立刻触发通用保护异常。这一层是硬件强制的你不需要写任何检查代码CPU 自己就把门关上了。我在早期版本里曾经天真地想过“在 C 里判断一下当前进程是不是用户进程再决定要不要执行危险操作”事实证明这种软件检查毫无意义——只要有一条路径漏判隔离就全线崩塌。页表解决的是“能不能访问某块内存”。这一层靠页目录项和页表项里的 U/S 位实现CPL3 时只能访问 U/S1 的页。这里有个很多人第一次都会踩的细节页表的每一级都要置位页目录项和页表项任意一级的 U/S 为 0整页对用户态就是不可见的。我第一次只改了页表项忘了页目录项表现是用户程序一取指就 #PF错误码 bit2 是 1用户态访问、bit0 是 0保护违规盯着错误码看了半小时才反应过来。栈解决的是“切换特权级时现场往哪儿放”。CPU 从 ring3 掉进 ring0 时绝不会继续用用户栈——那样用户程序就能伪造内核的返回地址。它会去 TSS 里读 SS0 和 ESP0硬切到一个内核专属的栈上。这是三根支柱里最容易被忽略的一根因为它平时完全不可见只有在你忘了设置 TSS 或者 esp0 指向垃圾时才会以三重故障的形式给你一个黑屏。注意这三根支柱是“与”的关系缺一根都不叫用户进程。只降特权级不隔离页表用户程序仍然能读写内核数据只隔离页表不设 TSS第一次中断进来系统就死。1.2 这一篇要打通的四个关卡把目标拆细一点这一篇实际要干的事情是四件。第一件是改 GDT加上 DPL3 的代码段和数据段描述符再加一个 TSS 描述符。第二件是把用户程序的映像搬进一块独立的虚拟地址区间并且把这整段映射的 U/S 位全部打开。第三件是构造一个“从中断返回”的假现场让iret指令把 CPU 从 ring0 带到 ring3这一步是整个功能的分水岭。第四件是提供一个int 0x80的系统调用入口让用户进程在需要内核帮忙时能主动回来干完活再安全地回到用户态。这四件事有严格的依赖顺序。GDT 没改iret里的 CS 选择子 DPL 就对不上直接 #GP页表没打开 U/S 位跳过去第一条指令就 #PF系统调用没做用户程序跑起来就是个死循环你还得靠时钟中断强行抢回控制权体验极差。我建议按这个顺序一个一个验证每做完一步就用一条专门的测试指令确认它真的生效了别攒到最后一起调。2. 特权级切换的硬件底座TSS 与 GDT 的改造GDT 是这个系列里最早写的东西前面一直只放了两三项空描述符、内核代码段、内核数据段。这次要往里塞三样新东西——用户代码段、用户数据段、TSS。改动不大但每一项的字段都错不起尤其是访问权限字节。我第一次写的时候把用户代码段的 DPL 填成 0选择子却是 0x1BRPL3iret执行瞬间就 #GP而且因为当时异常处理本身还有 bug直接进了三重故障重启循环连打印都看不到。2.1 段描述符里的 DPL 到底怎么填标准的扁平模型下四个段的基址都是 0、限长都是 4GB唯一区别就是访问权限字节。内核代码段是0x9A存在、DPL0、代码段、可读内核数据段是0x92存在、DPL0、数据段、可写。用户代码段把 DPL 改成 3就是0xFA用户数据段同理是0xF2。这些数值不用背记住结构就行bit7 存在位bit6-5 DPLbit4 描述符类型1 表示代码或数据段bit3 可执行位bit1 可读或可写位。选择子那边更有讲究。用户代码段的选择子如果选在 GDT 第 4 项索引 3就是33 0x18加上 RPL3 得到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 描述符的类型码是0x8932 位可用 TSSDPL0限长至少要覆盖 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正是我们要的效果。要是忘了设这个字段它会是 0CPU 会跑去 TSS 开头把 ESP0 之类的数据当位图解析行为就完全不可预测了。2.3 内核栈的分配方式与初始化时机每个用户进程必须独占一个内核栈大小 4KB 足够我现在的用量中断现场 一层函数调用。分配方式很朴素内核的物理内存管理已经能给出连续页了直接要一页因为内核页表是线性映射的物理地址可以直接当虚拟地址用不需要额外映射。初始化时机上我踩过一个顺序坑一开始我在进程创建函数里先把 esp0 写好再分配内核栈结果两者对不上。正确顺序是先分配栈、算出栈顶、再写进 TSS 的 esp0或者写进 PCB切换时再刷到 TSS。另外还要注意进程第一次进入用户态之前内核栈应该是空的栈顶上不要留任何“返回地址”。因为 CPU 从 ring3 掉进 ring0 时会自己切换到 esp0 然后压现场你如果事先在栈里留了东西返回时就会把它们当成现场的一部分弹出来现场就乱了。3. 地址空间搭建把程序放进 0x08048000内核线程的代码和数据直接躺在内核镜像里用户进程不行。它需要一块“内核看不见、用户随便用”的地盘。我的做法是给每个用户进程建一个独立的页目录把内核空间前 64MB 左右直接共享同一份页表剩下的高地址区间留给用户进程自己用。这样做的理由很直接共享内核页表意味着中断进来时 CPU 换到内核栈后取指、取数据全都能正常工作不需要任何临时的页表切换而用户区独立保证了两个用户进程之间互不可见。3.1 页表项里的 U/S 位前面提过要点这里说得再细一点。页目录项和页表项的 bit2 是 User/Supervisorbit1 是 Read/Write。要构造一个用户可访问的页正确的值应该是0x07存在 可写 用户而不是0x03。我第一次为了省事只写了0x03页表能建起来内核访问也没问题但用户态一跑就 #PF——而且是在很后面才触发因为你可能先跑了一段只是取指的代码直到某条写操作才暴露出来。还有一条容易忽略PMD/PDE 里的用户位是“下传”的如果页目录项 U/S0即使页表项 U/S1整页对用户态依然不可见。这在排查时非常具有迷惑性因为你在调试器里 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 0x08048054p_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 固定为 1IF1 开中断 */ push $0x1B /* CSDPL3 */ push %eax /* EIP */ iretEFLAGS 里的0x202值得单独说。bit1 是保留位必须为 1bit9 是 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 项必须是一个 DPL3 的中断门类型 0xE不是陷阱门也不是任务门。中断门的特点是进入时自动清 IF也就是关中断这样内核在处理系统调用的过程中不会被时钟打断避免了重入的一堆麻烦事返回时iret会恢复原来的 EFLAGS用户态的 IF1 自然就恢复了。陷阱门不清 IF用在这里会引入不必要的并发问题。DPL 必须设成 3否则用户态执行int 0x80直接 #GP。这一点跟其他异常处理项DPL0不同写 IDT 初始化代码时容易顺手全填 0然后系统调用一直失败。我第一次就是这么写的用户程序第一条write直接报 #GPerr_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无当前 pid4sys_brknew_break新的堆顶地址5sys_yield无05.3 sys_write 与 sys_exit 的实际实现sys_write目前只支持 fd1标准输出直接往内核维护的文本缓冲里写再由定时刷新到屏幕上。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 和 ptrlen 都按 4KB 向下/向上取整然后遍历涉及的每一个页检查该页在当前进程页目录里存在、且 U/S 位为 1。任何一页不满足就直接返回 -1对应 EFAULT。这个检查本身不慢因为一次系统调用的数据量都很小多走几步页表遍历完全无感。提示检查完到真正读取之间理论上有 TOCTOU 窗口但在这个单核、系统调用期间关中断的场景下不存在并发修改所以可以放心。等以后做了多核或者允许内核态被抢占这里就得改成先拷贝到内核缓冲区再校验。6. 调试实录从三重故障到用户程序跑起来前面几节讲的都是“正确的样子”但真实开发过程里我改坏的次数远多于改对。这一节把几个最有代表性的故障复盘一下包括症状、我怎么定位、根因是什么。这些坑我基本每个都踩过至少一次有些还踩了两次。6.1 症状、定位与修复过程故障一一执行iret就重启。现象是屏幕日志打到“entering ring3”然后整个模拟器复位没有任何异常打印。三重故障的典型特征就是没有输出因为它连异常处理都跑不起来了。定位方法是把 gdb 挂上去在user_enter的iret那行下断点单步执行看是哪条指令触发的。结果发现iret直接变成了复位。进一步检查栈内容发现压的顺序搞反了——我先压了 EIP 再压 CSiret把 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看 CPLCS 低两位、用x/8wx $esp看栈上的现场、用monitor info registers交叉验证。异常处理里我加了打印 EIP、错误码、CR2 的逻辑虽然屏幕输出简陋但比没有任何信息强太多。另外一个小技巧我给进程切换加了可选日志只在编译时打开DEBUG_SWITCH宏才输出每次切换打印“pid_from - pid_to”。这个日志在排查“现场被踩”类问题时价值极高因为你能清楚看到是哪一次切换之后行为开始异常。以前我靠满屏 printf 硬猜加上这个之后定位时间从几小时缩短到几分钟。还有一个习惯值得推荐给用户程序写一个最小的测试程序只做三件事——打印一个字符、调用getpid、调用exit。先保证这个能跑通再上复杂程序。我一开始直接拿一个循环打印的程序去测结果因为系统调用有 bug第一次调用就崩根本分不清是加载错了还是调用错了。把测试粒度降下来是省时间不是费时间。写到这里用户进程这条链路基本通了。回顾下来最深的体会是真正难的不是某一行代码而是理解 CPU 在特权级切换时替你做了哪些事、又从哪儿读参数。TSS 的 esp0、页表的 U/S 位、iret的栈布局这三处只要有一处理解偏了现象就会以完全不相干的样子出现逼着你从现象倒推。我现在习惯在改任何涉及特权级的代码前先在纸上把栈的最终形态画出来标清楚每个位置是谁压的、谁弹的这个动作至少帮我省掉了一半的调试时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑