资讯动态

30天自制操作系统:系统调用机制从内核态到用户态的完整实现

发布时间:2026/10/20 8:39:16 来源:尧图企业网站定制
1. 从内核态到用户态的那道门做操作系统开发到第十九天基本都会撞上同一堵墙内核里各种功能都跑通了键盘能读、鼠标能点、定时器能跳但应用程序还是只能跟内核代码编译在一起改一行应用逻辑就得重烧整个系统镜像。这种“应用和内核焊死”的做法在玩具阶段没问题一旦想让多个程序共存、想让应用独立编译就彻底玩不转了。系统调用System Call简称 syscall就是拆掉这堵墙的那把锤子。它本质上是内核向应用程序开放的一组“官方接口”应用程序通过特定指令主动陷入内核内核根据传入的编号找到对应处理函数执行完再把结果返回给应用。这套机制在 Linux 上叫 syscall在 Windows 上叫 API叫法不同骨子里的逻辑一模一样。我这次要拆解的就是在一个 30 天自制操作系统的第十九天里怎么从零把系统调用这套机制搭起来。核心目标有三个第一让应用程序能通过INT 0x40这样的软中断进入内核第二内核能根据寄存器里的功能号分发到不同的处理函数第三把putchar、getkey、malloc这类基础能力封装成应用可调用的 API。适合谁看适合已经写完中断处理、能加载并运行简单应用程序、但还没打通“应用调内核”这条路的开发者。如果你连 GDT、IDT 都还没搞明白建议先把前十八天的内容补完再来看这篇。2. 系统调用的整体设计与选型考量2.1 为什么用软中断而不是函数指针实现系统调用有好几种路子最朴素的一种是内核在初始化时把函数地址写到一个固定内存位置应用程序直接读那个地址然后CALL过去。这种做法我早期试过能跑但问题一大堆。首先是权限问题应用程序直接跳到内核代码段执行CPU 的权限检查形同虚设一个野指针就能把内核数据踩烂。其次是可移植性差函数地址一旦变动所有应用都得重新编译。软中断方案就干净很多。应用程序执行INT 0x40CPU 根据 IDT 里 0x40 号门描述符自动切换到内核代码段、切换到内核栈然后跳转到我们注册的处理函数。整个过程由硬件保证权限切换应用根本碰不到内核的代码段和数据段。这也是为什么主流操作系统都走中断门这条路——它把“权限边界”这件事交给了 CPU 硬件去守而不是靠软件自觉。选0x40这个中断号也是有讲究的。0x00 到 0x1F 被 CPU 保留给异常0x20 到 0x2F 通常给定时器、键盘这些硬件中断0x30 往后才是我们自由发挥的空间。我选 0x40 纯粹是留足余量方便以后加更多系统调用入口。2.2 参数传递寄存器是唯一靠谱的通道应用调用内核函数总得传参数。比如putchar(c)要传字符malloc(size)要传大小。问题来了应用和内核不共享栈参数怎么传有人会想把参数压到应用栈上内核去读应用栈不就行了。理论上可行但应用栈的地址在内核里是“用户空间地址”访问它需要额外的地址映射处理而且一旦应用传了个恶意指针内核读参数时就可能触发页错误。所以最稳妥的做法是用寄存器传参。x86 架构下我约定EAX放功能号EBX、ECX、EDX、ESI、EDI依次放第 1 到第 5 个参数。为什么是这几个寄存器因为它们都是通用寄存器应用侧用起来方便而且EAX在 x86 调用约定里本来就常用来放返回值复用它放功能号返回时直接覆盖成结果逻辑很顺。超过 5 个参数怎么办那就传一个指针指向应用空间里的一块参数结构体内核再按约定去解析。不过在实际开发中5 个参数基本够用了malloc、free、putchar、getkey、open这些常见调用参数都不超过 3 个。2.3 功能号分配与 API 表设计功能号就是系统调用的“身份证”。我采用连续编号的方式从 1 开始往上排功能号API 名称参数返回值说明1putcharEBX字符无向控制台输出一个字符2getkey无EAX按键码阻塞读取一个按键3mallocEBX字节数EAX地址分配内存失败返回 04freeEBX地址无释放内存5exit无不返回结束当前应用6openEBX文件名指针EAX文件句柄打开文件7readEBX句柄, ECX缓冲区, EDX长度EAX实际读取字节数读文件8writeEBX句柄, ECX缓冲区, EDX长度EAX实际写入字节数写文件这张表是整个系统调用的核心。内核侧用一个函数指针数组来存处理函数数组下标就是功能号。应用侧则用一个头文件把这些编号定义成宏比如#define API_PUTCHAR 1这样应用代码里写api_putchar(A)编译后就是往EAX写 1、往EBX写 A、然后INT 0x40。注意功能号一旦发布就不要再改否则所有已编译的应用都会失效。如果确实需要调整宁可新增一个功能号也不要动老的。3. 内核侧的实现细节与实操要点3.1 注册中断门让 0x40 号中断有归宿系统调用的第一步是在 IDT 里给 0x40 号中断注册一个门描述符。这一步和注册键盘中断、定时器中断的流程几乎一样唯一的区别是门类型要设成“中断门”而不是“陷阱门”。中断门和陷阱门的区别在于对中断标志位 IF 的处理。中断门会自动清 IF也就是进入处理函数后硬件中断被屏蔽陷阱门不清 IF硬件中断还能进来。系统调用处理过程中我不希望被定时器中断打断去切换任务所以选中断门更稳妥。当然如果处理函数执行时间很长长时间屏蔽中断会导致系统响应变差所以系统调用处理函数里要尽量快进快出别做耗时操作。注册代码大致长这样// 设置 0x40 号中断门指向 syscall_handler set_gate(0x40, (unsigned int)syscall_handler, 0x08, 0x8E);其中0x08是内核代码段选择子0x8E表示这是一个存在的中断门、DPL0只有内核态能触发。这里有个细节如果 DPL 设成 0应用程序执行INT 0x40时 CPU 会检查当前特权级发现是用户态而门要求内核态就会触发通用保护异常。所以系统调用门的 DPL 必须设成 3允许用户态触发。这一点我踩过坑当时应用一执行INT 0x40系统就重启查了半天才发现是 DPL 没放开。3.2 汇编入口保存现场与栈切换INT 0x40触发后CPU 会自动做几件事从 TSS 里读取内核栈的 SS 和 ESP把当前的 SS、ESP、EFLAGS、CS、EIP 依次压入内核栈然后跳转到 IDT 里登记的处理函数。注意CPU 只压了这 5 个东西通用寄存器的值还是应用态的需要我们自己保存。所以汇编入口的第一件事就是PUSHAD把 EAX、ECX、EDX、EBX、ESP、EBP、ESI、EDI 全部压栈。然后调用 C 语言写的分发函数把栈指针传过去。分发函数执行完再POPAD恢复寄存器最后IRETD返回应用。_syscall_handler: PUSHAD PUSH DS PUSH ES MOV EAX, ESP PUSH EAX CALL _syscall_dispatch ADD ESP, 4 POP ES POP DS POPAD IRETD这里有个容易忽略的点进入中断处理函数后DS 和 ES 还是应用态的数据段选择子。如果 C 代码里要访问内核数据必须先把 DS、ES 换成内核数据段。我在汇编入口里加了PUSH DS/PUSH ES然后在 C 函数开头用MOV AX, 0x10/MOV DS, AX切换返回前再恢复。不切换的话C 代码里一个全局变量访问就可能读到应用空间去症状是数据莫名其妙不对排查起来非常痛苦。3.3 C 分发函数功能号路由与参数提取分发函数是系统调用的“总调度台”。它从栈里拿到保存的寄存器结构体指针根据EAX里的功能号查表找到对应的处理函数把EBX、ECX、EDX作为参数传进去最后把返回值写回EAX。struct REGS { unsigned int edi, esi, ebp, esp, ebx, edx, ecx, eax; // ... 其他字段 }; int syscall_dispatch(struct REGS *regs) { int func regs-eax; int ret -1; switch (func) { case API_PUTCHAR: ret sys_putchar(regs-ebx); break; case API_GETKEY: ret sys_getkey(); break; case API_MALLOC: ret (int)sys_malloc(regs-ebx); break; // ... 其他功能 default: ret -1; break; } regs-eax ret; return 0; }用switch-case还是函数指针数组看个人习惯。switch-case的好处是编译器可能优化成跳转表执行效率高而且调试时断点好打。函数指针数组的好处是扩展方便新增功能只要往数组里加一项。我两种都用过最后选了switch-case因为功能数量不多而且调试期能直接在 case 里打断点比追函数指针方便。提示分发函数里一定要对功能号做范围检查。应用传个 999 进来如果不检查就直接查表很可能跳到非法地址导致系统崩溃。加一个if (func 1 || func MAX_API) return -1;就能挡住大部分误用。3.4 内存管理 API 的实现要点malloc和free是应用最常用的两个系统调用也是实现起来最需要小心的。内核里的内存管理和应用里的malloc不一样应用申请的内存必须在内核的堆里分配然后把地址返回给应用。应用拿到地址后直接读写这块内存因为应用和内核共享同一个物理地址空间在没开分页的简易系统里。我的做法是在内核堆里维护一个简单的空闲链表。每个内存块头部存一个size字段和next指针分配时遍历链表找足够大的块找到后如果剩余空间还够就切分成两块一块返回给应用一块留在空闲链表里。释放时把块重新挂回链表并检查相邻块能否合并。这里有个坑应用传进来的free地址必须是之前malloc返回的地址不能是任意地址。如果应用传了个野指针内核直接去操作那块内存的头部很可能把内核数据结构踩坏。所以我在free里加了一个简单的校验检查地址是否落在内核堆的范围内不在范围内就直接返回错误码不做任何操作。这个校验不能百分百挡住所有误用但能挡住大部分低级错误。4. 应用侧 API 封装与完整调用流程4.1 头文件设计让应用写起来像调普通函数应用侧不应该直接写INT 0x40和寄存器操作那样太原始了。我写了一个api.h把每个系统调用封装成 C 函数。应用只要#include api.h就能像调普通函数一样用。// api.h #define API_PUTCHAR 1 #define API_GETKEY 2 #define API_MALLOC 3 #define API_FREE 4 #define API_EXIT 5 void api_putchar(int c); int api_getkey(void); void *api_malloc(int size); void api_free(void *p); void api_exit(void);实现放在api.c里每个函数就是设置寄存器、执行INT 0x40、取返回值void api_putchar(int c) { __asm__ volatile ( movl %0, %%eax\n movl %1, %%ebx\n int $0x40\n : : r(API_PUTCHAR), r(c) : eax, ebx, memory ); }用内联汇编而不是单独写.asm文件好处是编译时直接嵌进 C 代码不用管汇编器和 C 编译器的链接问题。memory这个 clobber 是告诉编译器这段汇编可能修改内存别把变量缓存到寄存器里。不加的话编译器优化后可能读到旧值症状是调用结果时对时错非常难查。4.2 一个完整的应用示例写个最简单的应用循环读按键按到回车就退出按到其他键就回显#include api.h void main(void) { api_putchar(A); api_putchar(P); api_putchar(I); api_putchar(\n); for (;;) { int key api_getkey(); if (key 0x1C) { // 回车键的扫描码 api_exit(); } api_putchar(key); } }这个应用编译后通过内核的加载器加载到内存某个地址然后内核跳过去执行。执行到api_putchar时应用执行INT 0x40CPU 切到内核栈内核分发到sys_putchar往显存写字符然后返回应用。整个过程应用完全感知不到内核的存在就像在调一个普通库函数。4.3 从应用到内核再回来的完整时序把整个流程串一遍方便你对照调试应用调用api_putchar(X)内联汇编把EAX设为 1EBX设为 X。执行INT 0x40CPU 从 IDT 读取 0x40 号门描述符检查 DPL3 允许用户态触发。CPU 切换到 TSS 里登记的内核栈压入应用态的 SS、ESP、EFLAGS、CS、EIP。跳转到_syscall_handler执行PUSHAD保存通用寄存器切换 DS/ES 到内核数据段。调用syscall_dispatch从regs-eax读到功能号 1进入case API_PUTCHAR。sys_putchar把regs-ebx里的字符写到显存对应位置。返回分发函数把返回值写回regs-eax。汇编入口恢复 DS/ESPOPAD恢复寄存器IRETD弹出 EIP、CS、EFLAGS、ESP、SS回到应用态。应用继续执行api_putchar之后的代码。整个链路里最容易出问题的环节是第 3 步和第 8 步。第 3 步如果 TSS 里的内核栈地址没设对一触发中断就三重故障重启。第 8 步如果IRETD前栈不平衡返回地址就错了应用会跳到随机位置执行。调试时可以在_syscall_handler入口和IRETD前各打一个断点看栈指针是否一致。5. 常见问题与排查技巧实录5.1 应用一执行 INT 就重启这是最常见的问题症状是应用刚调用 API 系统就重启连错误信息都没有。原因通常有三个症状可能原因排查方法执行 INT 立即重启IDT 里 0x40 号门没注册检查set_gate是否在初始化时调用执行 INT 立即重启门描述符 DPL 设为 0改成 3允许用户态触发执行 INT 立即重启TSS 里内核栈地址为 0检查 TSS 初始化代码执行 INT 后花屏处理函数写显存地址算错检查sys_putchar的显存偏移计算执行 INT 后卡死处理函数里死循环在处理函数入口加调试输出我遇到最多的是 DPL 问题。当时门描述符的 type 字段写的是0x8E这个值的二进制是10001110其中 bit 5-6 是 DPL00表示 DPL0。改成0xEE11101110后 DPL3应用就能正常触发了。这个细节在 Intel 手册里写得很清楚但第一次做很容易忽略。5.2 返回值不对或寄存器被踩应用调用api_getkey拿到的返回值总是 0或者调用完api_putchar后应用里的变量莫名其妙变了。这类问题基本都出在寄存器保存和恢复上。PUSHAD压栈的顺序是 EAX、ECX、EDX、EBX、ESP、EBP、ESI、EDIPOPAD是反序弹出。如果你在 C 结构体里定义的字段顺序和这个不一致读到的参数就是错的。我的struct REGS里字段顺序是edi, esi, ebp, esp, ebx, edx, ecx, eax正好和PUSHAD的压栈顺序相反因为栈是向下增长的最后压入的 EAX 在最低地址对应结构体的最后一个字段。另一个坑是内联汇编的 clobber 列表。如果忘了写memory编译器可能把api_putchar调用前后的变量访问优化到寄存器里导致调用后读到的还是旧值。加上memory后编译器会强制重新从内存读取问题就消失了。5.3 malloc 返回的地址应用一写就崩应用拿到malloc返回的地址后往那块内存写数据结果系统崩溃。原因通常是内核堆和应用空间重叠了。在简易系统里内核和应用的地址空间没有严格隔离如果内核堆的起始地址设得太低和应用代码段重叠应用一写就把自己的代码覆盖了。我的做法是在链接脚本里明确划分区域内核代码和数据放在低地址内核堆紧跟在后面应用加载地址放在更高的位置中间留一段保护间隙。这样即使应用越界写也要写穿整个间隙才能碰到内核数据给调试留出反应时间。提示可以在malloc返回的地址前后各加一个魔数标记free时检查魔数是否被改写。如果被改写说明应用越界写了直接报错而不是继续操作能避免更大的破坏。5.4 系统调用执行太慢导致按键丢失api_getkey是阻塞式的应用调用后会一直等直到有按键。如果内核在处理getkey时把中断屏蔽了键盘中断进不来按键就会丢。我的做法是在sys_getkey里用一个循环循环里执行HLT指令让 CPU 休眠等键盘中断唤醒。键盘中断处理函数把按键码存到一个缓冲区sys_getkey从缓冲区取。这样既不会忙等浪费 CPU也不会丢按键。但这里有个细节HLT之后 CPU 会休眠直到下一个中断到来。如果此时键盘中断被屏蔽了CPU 就永远醒不过来。所以sys_getkey执行期间必须保证键盘中断是开启的。我在进入sys_getkey前用STI开中断取到按键后再CLI关中断保证临界区安全。5.5 应用退出后系统卡死api_exit的实现要特别小心。应用调用exit后不能简单地从处理函数返回因为返回后应用还会继续执行exit之后的代码。正确的做法是在sys_exit里把应用标记为“已结束”然后触发一个任务切换切回内核主循环或者切换到下一个应用。我最初的做法是在sys_exit里直接改IRETD要返回的 EIP把它指向内核的某个安全地址。但这样栈不平衡IRETD弹出的是应用栈的 SS/ESP回到内核后栈就乱了。后来改成在sys_exit里设置一个标志分发函数检查到这个标志后不执行正常的IRETD而是跳转到任务切换代码由任务切换代码负责恢复内核栈。这个改动涉及汇编和 C 的配合调试了好一阵才跑通。6. 系统调用的扩展方向与个人体会系统调用这套机制跑通之后整个操作系统的扩展性会上一个台阶。以前加一个新功能要改内核、重编译、重烧镜像现在只要在内核里加一个处理函数、分配一个功能号应用侧加一个封装函数就能用了。应用和内核彻底解耦可以独立编译、独立加载。后续可以往几个方向扩展。一是增加文件系统相关的调用open、read、write、close这一套加上去应用就能读写文件了。二是增加图形相关的调用create_window、draw_pixel、draw_string应用就能画窗口了。三是增加进程相关的调用fork、exec、wait应用就能创建子进程了。每加一个功能都是在内核里加一个 case、在头文件里加一个宏改动量很小。我在实际开发中的体会是系统调用的难点不在单个功能的实现而在“边界”的处理。应用和内核之间的数据传递、权限切换、错误处理每一个环节都可能出问题。我的建议是先把最简单的putchar跑通确认整条链路没问题再往上加复杂功能。每加一个功能就写一个测试应用确认返回值正确、寄存器没被踩、系统没崩溃。这样一步步来比一口气写完所有功能再调试要快得多。最后分享一个小技巧在分发函数里加一个调试开关打开后每次系统调用都往串口或屏幕输出一行日志格式是[API] func1 ebx0x41。这样应用调用 API 时你能实时看到内核收到了什么参数、返回了什么结果。调试期这个日志非常有用能省下大量猜测的时间。等系统稳定后再把开关关掉不影响性能。

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

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

免费获取报价 →
↑