☰
30天自制操作系统:系统调用机制从内核态到用户态的完整实现
2026/10/20 8:39:06 网站建设 项目流程

1. 从内核态到用户态的那道门

做操作系统开发到第十九天,基本都会撞上同一堵墙:内核里各种功能都跑通了,键盘能读、鼠标能点、定时器能跳,但应用程序还是只能跟内核代码编译在一起,改一行应用逻辑就得重烧整个系统镜像。这种“应用和内核焊死”的做法,在玩具阶段没问题,一旦想让多个程序共存、想让应用独立编译,就彻底玩不转了。

系统调用(System Call,简称 syscall)就是拆掉这堵墙的那把锤子。它本质上是内核向应用程序开放的一组“官方接口”,应用程序通过特定指令主动陷入内核,内核根据传入的编号找到对应处理函数,执行完再把结果返回给应用。这套机制在 Linux 上叫 syscall,在 Windows 上叫 API,叫法不同,骨子里的逻辑一模一样。

我这次要拆解的,就是在一个 30 天自制操作系统的第十九天里,怎么从零把系统调用这套机制搭起来。核心目标有三个:第一,让应用程序能通过INT 0x40这样的软中断进入内核;第二,内核能根据寄存器里的功能号分发到不同的处理函数;第三,把putchar、getkey、malloc这类基础能力封装成应用可调用的 API。适合谁看?适合已经写完中断处理、能加载并运行简单应用程序、但还没打通“应用调内核”这条路的开发者。如果你连 GDT、IDT 都还没搞明白,建议先把前十八天的内容补完再来看这篇。

2. 系统调用的整体设计与选型考量

2.1 为什么用软中断而不是函数指针

实现系统调用有好几种路子,最朴素的一种是:内核在初始化时把函数地址写到一个固定内存位置,应用程序直接读那个地址然后CALL过去。这种做法我早期试过,能跑,但问题一大堆。首先是权限问题,应用程序直接跳到内核代码段执行,CPU 的权限检查形同虚设,一个野指针就能把内核数据踩烂。其次是可移植性差,函数地址一旦变动,所有应用都得重新编译。

软中断方案就干净很多。应用程序执行INT 0x40,CPU 根据 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=地址分配内存,失败返回 0
4freeEBX=地址无释放内存
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表示这是一个存在的中断门、DPL=0(只有内核态能触发)。这里有个细节:如果 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 0x40,CPU 切到内核栈,内核分发到sys_putchar,往显存写字符,然后返回应用。整个过程应用完全感知不到内核的存在,就像在调一个普通库函数。

4.3 从应用到内核再回来的完整时序

把整个流程串一遍,方便你对照调试:

  1. 应用调用api_putchar('X'),内联汇编把EAX设为 1,EBX设为 'X'。
  2. 执行INT 0x40,CPU 从 IDT 读取 0x40 号门描述符,检查 DPL=3 允许用户态触发。
  3. CPU 切换到 TSS 里登记的内核栈,压入应用态的 SS、ESP、EFLAGS、CS、EIP。
  4. 跳转到_syscall_handler,执行PUSHAD保存通用寄存器,切换 DS/ES 到内核数据段。
  5. 调用syscall_dispatch,从regs->eax读到功能号 1,进入case API_PUTCHAR。
  6. sys_putchar把regs->ebx里的字符写到显存对应位置。
  7. 返回分发函数,把返回值写回regs->eax。
  8. 汇编入口恢复 DS/ES,POPAD恢复寄存器,IRETD弹出 EIP、CS、EFLAGS、ESP、SS,回到应用态。
  9. 应用继续执行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 是 DPL,00表示 DPL=0。改成0xEE(11101110)后 DPL=3,应用就能正常触发了。这个细节在 Intel 手册里写得很清楚,但第一次做很容易忽略。

5.2 返回值不对或寄存器被踩

应用调用api_getkey拿到的返回值总是 0,或者调用完api_putchar后应用里的变量莫名其妙变了。这类问题基本都出在寄存器保存和恢复上。

PUSHAD压栈的顺序是 EAX、ECX、EDX、EBX、ESP、EBP、ESI、EDI,POPAD是反序弹出。如果你在 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] func=1 ebx=0x41。这样应用调用 API 时,你能实时看到内核收到了什么参数、返回了什么结果。调试期这个日志非常有用,能省下大量猜测的时间。等系统稳定后再把开关关掉,不影响性能。

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

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

立即咨询