用户程序不能直接调用内核函数——那是特权级隔离的基本规则。那怎么请求内核服务?系统调用是用户空间和内核空间之间唯一的合法通道。上一章实现了 fork 和 exec 两个系统调用,本章将完善整个系统调用框架,实现 20 个 POSIX 风格的接口,为 Shell 和用户工具提供完整的基础服务。
系统调用是操作系统中最重要的抽象之一,它允许运行在用户态的程序安全地请求内核提供的特权服务,如文件操作、进程管理、I/O 操作等。
系统调用概述
在理解系统调用之前,需要回答一个根本问题:为什么不能让用户程序直接访问内核资源?
安全性问题:如果用户程序可以直接访问硬件资源,恶意程序可能会破坏系统稳定性、窃取其他程序的数据,甚至导致系统崩溃。x86 CPU 提供了特权级机制(Ring 0 ~ Ring 3),将内核运行在最高特权级(Ring 0),用户程序运行在低特权级(Ring 3)。
抽象性需求:硬件接口通常非常复杂且多样化。系统调用为用户程序提供了一个统一的、稳定的 API,隐藏了底层硬件的细节。
可移植性:通过系统调用这一标准接口,用户程序可以在不同的硬件平台上运行,而无需修改代码。
系统调用的作用就像一个”请求窗口”,用户程序通过这个窗口向内核提出请求,内核验证权限后执行相应的操作,最后将结果返回给用户程序。
系统调用的核心设计
系统调用表
系统调用表是一个函数指针数组,它将系统调用号映射到对应的内核函数。由于系统调用是通过中断触发的,内核需要一个快速查找机制,根据用户传入的调用号找到并执行相应的处理函数。
系统调用表的设计遵循以下原则:
- 快速查找:数组索引提供 O(1) 的查找效率
- 类型统一:所有系统调用函数使用相同的函数指针类型
- 可扩展性:可以轻松添加新的系统调用
系统调用号的定义在 syscall.h 中:
#define SYS_EXIT 0 // 进程退出#define SYS_READ 1 // 读取文件#define SYS_WRITE 2 // 写入文件#define SYS_YIELD 3 // 让出CPU#define SYS_GETPID 4 // 获取进程ID#define SYS_SLEEP 5 // 睡眠#define SYS_PUTS 6 // 打印字符串#define SYS_GETTID 7 // 获取线程ID#define SYS_OPEN 8 // 打开文件#define SYS_CLOSE 9 // 关闭文件#define SYS_MKDIR 10 // 创建目录#define SYS_READDIR 11 // 读取目录项#define SYS_GETCWD 12 // 获取当前目录#define SYS_CHDIR 13 // 改变当前目录#define SYS_FORK 14 // 创建进程#define SYS_EXEC 15 // 执行程序#define SYS_WAIT 16 // 等待子进程#define SYS_UPTIME 17 // 获取系统运行时间#define SYS_GETCHAR 18 // 获取键盘输入#define SYS_PS 19 // 显示进程列表
#define SYSCALL_COUNT 20 // 系统调用总数系统调用表在 syscall.c 中初始化:
static syscall_handler_t syscall_table[SYSCALL_COUNT];
void syscall_init(void){ for (int i = 0; i < SYSCALL_COUNT; i++) syscall_table[i] = NULL;
syscall_table[SYS_EXIT] = (syscall_handler_t)sys_exit; syscall_table[SYS_READ] = (syscall_handler_t)sys_read; syscall_table[SYS_WRITE] = (syscall_handler_t)sys_write; syscall_table[SYS_YIELD] = (syscall_handler_t)sys_yield; syscall_table[SYS_GETPID] = (syscall_handler_t)sys_getpid; // ... 更多系统调用注册
register_interrupt_handler(0x80, syscall_handler); vga_printf("[Syscall] Initialized (%d syscalls)\n", SYSCALL_COUNT);}系统调用处理器的执行流程如下:
参数传递机制
系统调用需要在用户空间和内核空间之间传递参数。由于系统调用是通过中断触发的,不能使用常规的函数调用约定。x86 约定使用特定的寄存器传递参数,这种约定被称为 System V ABI 调用约定。
我们的系统调用使用以下寄存器传递参数:
eax:系统调用号ebx:第一个参数ecx:第二个参数edx:第三个参数
例如,调用 sys_read(fd, buf, count) 时:
mov eax, SYS_READ ; 系统调用号mov ebx, fd ; 第一个参数:文件描述符mov ecx, buf ; 第二个参数:缓冲区地址mov edx, count ; 第三个参数:读取字节数int 0x80 ; 触发中断; 返回值在 eax 中这种设计的优点:
- 快速:参数在寄存器中传递,无需访问内存
- 简单:固定的寄存器映射,易于理解和实现
- 兼容:与 Linux 的系统调用约定保持一致
中断处理程序会从 interrupt_frame_t 结构中提取这些参数:
void syscall_handler(interrupt_frame_t *frame){ uint32_t num = frame->eax; // 系统调用号 uint32_t a1 = frame->ebx; // 第一个参数 uint32_t a2 = frame->ecx; // 第二个参数 uint32_t a3 = frame->edx; // 第三个参数 int ret = -1;
if (num < SYSCALL_COUNT && syscall_table[num]) ret = syscall_table[num](a1, a2, a3);
frame->eax = (uint32_t)ret; // 返回值}系统调用的分类
我们的 20 个系统调用可以按照功能分为以下几类:
代码实现
文件结构
16.kernel-syscall/├── boot/│ ├── mbr.S # MBR 引导代码│ └── loader.S # Loader 引导代码├── kernel/│ ├── include/│ │ ├── syscall.h # 系统调用接口定义│ │ ├── interrupt.h # 中断处理框架│ │ ├── fs.h # 文件系统接口│ │ └── process.h # 进程管理接口│ ├── interrupt/│ │ ├── syscall.c # 系统调用实现│ │ └── interrupt.S # 中断处理汇编入口│ ├── task/│ │ ├── fork.c # fork 系统调用实现│ │ └── exec.c # exec 系统调用实现│ └── kernel.c # 内核主函数(系统调用测试)└── README.md # 章节文档interrupt_frame_t
这是中断发生时 CPU 自动压入的栈帧,汇编 stub 会在这个基础上补充更多信息:
typedef struct interrupt_frame{ /* 手动压入的段寄存器 */ uint32_t gs, fs, es, ds;
/* pusha 压入的通用寄存器 */ uint32_t edi, esi, ebp, esp_dummy, ebx, edx, ecx, eax;
/* stub 压入的:中断号和错误码 */ uint32_t int_no, err_code;
/* CPU 自动压入的 */ uint32_t eip, cs, eflags;
/* 仅在特权级变化时压入 */ uint32_t useresp, ss;} interrupt_frame_t;这个结构关键,因为:
- 它包含了用户程序的所有寄存器状态
- 参数通过
eax,ebx,ecx,edx传递 - 返回值通过修改
eax返回给用户程序 - 中断返回时会恢复这个栈帧,回到用户态继续执行
系统调用的完整执行流程:
系统调用初始化
void syscall_init(void){ // 清空系统调用表 for (int i = 0; i < SYSCALL_COUNT; i++) syscall_table[i] = NULL;
// 注册所有系统调用 syscall_table[SYS_EXIT] = (syscall_handler_t)sys_exit; syscall_table[SYS_READ] = (syscall_handler_t)sys_read; syscall_table[SYS_WRITE] = (syscall_handler_t)sys_write; syscall_table[SYS_YIELD] = (syscall_handler_t)sys_yield; syscall_table[SYS_GETPID] = (syscall_handler_t)sys_getpid; syscall_table[SYS_SLEEP] = (syscall_handler_t)sys_sleep; syscall_table[SYS_PUTS] = (syscall_handler_t)sys_puts; syscall_table[SYS_GETTID] = (syscall_handler_t)sys_gettid; syscall_table[SYS_OPEN] = (syscall_handler_t)sys_open; syscall_table[SYS_CLOSE] = (syscall_handler_t)sys_close; syscall_table[SYS_MKDIR] = (syscall_handler_t)sys_mkdir_sc; syscall_table[SYS_READDIR] = (syscall_handler_t)sys_readdir_sc; syscall_table[SYS_GETCWD] = (syscall_handler_t)sys_getcwd; syscall_table[SYS_CHDIR] = (syscall_handler_t)sys_chdir; syscall_table[SYS_FORK] = (syscall_handler_t)sys_fork; syscall_table[SYS_EXEC] = (syscall_handler_t)sys_exec; syscall_table[SYS_WAIT] = (syscall_handler_t)sys_wait; syscall_table[SYS_UPTIME] = (syscall_handler_t)sys_uptime; syscall_table[SYS_GETCHAR] = (syscall_handler_t)sys_getchar; syscall_table[SYS_PS] = (syscall_handler_t)sys_ps;
// 注册系统调用中断处理程序到 IDT register_interrupt_handler(0x80, syscall_handler);
vga_printf("[Syscall] Initialized (%d syscalls)\n", SYSCALL_COUNT);}解析:
- 首先将所有表项清零,避免未初始化的指针
- 使用函数指针类型转换注册每个系统调用
- 最关键的一行是注册中断处理程序:向量 0x80(十进制 128)专门用于系统调用
- 最后打印初始化信息,便于调试
系统调用处理程序
void syscall_handler(interrupt_frame_t *frame){ // 从寄存器中提取参数 uint32_t num = frame->eax; // 系统调用号 uint32_t a1 = frame->ebx; // 第一个参数 uint32_t a2 = frame->ecx; // 第二个参数 uint32_t a3 = frame->edx; // 第三个参数 int ret = -1; // 默认返回值(错误)
// 检查调用号是否合法,并调用对应的函数 if (num < SYSCALL_COUNT && syscall_table[num]) ret = syscall_table[num](a1, a2, a3);
// 将返回值写回 eax,用户程序可以通过 eax 获取返回值 frame->eax = (uint32_t)ret;}解析:
- 这是所有系统调用的统一入口点
- 直接从栈帧中读取寄存器值,无需额外的参数解析
- 边界检查确保不会访问越界的数组元素
- 返回值通过修改栈帧中的 eax 传回用户程序
- 错误处理:如果调用号无效,返回 -1
write 系统调用
int sys_write(int fd, const void *buf, uint32_t count){ // 特殊处理标准输出(fd=1)和标准错误(fd=2) if (fd == 1 || fd == 2) { const char *str = (const char *)buf; for (uint32_t i = 0; i < count; i++) vga_putc(str[i]); return (int)count; }
// 普通文件写入 return (int)fs_write(fd, buf, count);}解析:
write(1, ...)和write(2, ...)直接输出到 VGA 显示器- 其他文件描述符调用文件系统的写入函数
- 返回实际写入的字节数
- 这是用户程序打印输出的主要方式
read 系统调用
int sys_read(int fd, void *buf, uint32_t count){ return (int)fs_read(fd, buf, count);}解析:
- 相对简单的封装,直接调用文件系统的读取函数
- 返回实际读取的字节数
- 对于终端输入,需要检查键盘缓冲区
进程控制系统调用
int sys_getpid(void){ process_t *proc = current_process(); return proc ? (int)proc->pid : 0;}
int sys_gettid(void){ task_t *t = current_task(); return t ? (int)t->tid : 0;}
int sys_fork(void){ return sys_fork_impl();}
int sys_exec(const char *path){ return sys_exec_impl(path);}
int sys_wait(int *status){ return sys_wait_impl(status);}解析:
getpid和gettid从当前进程/线程结构中获取 IDfork、exec、wait是复杂的进程管理操作,在process.c中实现- 这些系统调用是进程控制的核心
系统调用测试任务
static void syscall_test_task(void *arg){ (void)arg;
vga_printf("\n--- System Call Test ---\n\n");
/* 测试进程/线程 ID */ int pid = sys_getpid(); int tid = sys_gettid(); vga_printf("sys_getpid() = %d\n", pid); vga_printf("sys_gettid() = %d\n", tid);
/* 测试 uptime */ int up = sys_uptime(); vga_printf("sys_uptime() = %d seconds\n", up);
/* 测试 puts */ vga_printf("sys_puts(): "); sys_puts("Hello from syscall!\n");
/* 测试 write(stdout) */ const char *msg = "sys_write(1, ...): works!\n"; sys_write(1, msg, strlen(msg));
/* 测试文件操作 */ vga_printf("\n--- File Syscalls ---\n");
sys_mkdir_sc("/test", FS_PERM_READ | FS_PERM_WRITE); vga_printf("Created /test directory\n");
int fd = sys_open("/test/data.txt", FS_OPEN_WRITE | FS_OPEN_CREATE); if (fd >= 0) { const char *data = "Syscall file content\n"; sys_write(fd, data, strlen(data)); sys_close(fd); vga_printf("Wrote to /test/data.txt\n"); }
fd = sys_open("/test/data.txt", FS_OPEN_READ); if (fd >= 0) { char buf[64]; memset(buf, 0, 64); int n = sys_read(fd, buf, 63); sys_close(fd); vga_printf("Read %d bytes: %s", n, buf); }
/* 测试 yield */ vga_printf("\nTesting yield (3 times)...\n"); for (int i = 0; i < 3; i++) { vga_printf(" Before yield %d\n", i); sys_yield(); vga_printf(" After yield %d\n", i); }
vga_printf("\n=== All Syscall Tests Passed ===\n"); while (1) schedule_yield();}解析:
- 这是一个内核任务,全面测试所有已实现的系统调用
- 测试进程 ID、时间、字符串输出、文件操作、让出 CPU 等功能
- 使用
sys_write(1, ...)和sys_puts()两种方式输出 - 文件操作测试包括创建目录、打开文件、读写文件
yield测试验证了进程调度的正确性
运行与验证
编译运行
cd 16.kernel-syscallmake clean && make all && make run预期输出
=== Chapter 16: System Calls ===
[Interrupt] IDT initialized[Timer] Timer initialized at 1000 Hz[Memory] PMM initialized[Memory] VMM initialized[Memory] Kernel heap initialized[Task] Task subsystem initialized[Process] Process subsystem initialized[Syscall] Initialized (20 syscalls)Starting scheduler...
--- System Call Test ---
sys_getpid() = 1sys_gettid() = 1sys_uptime() = 0 secondssys_puts(): Hello from syscall!sys_write(1, ...): works!
--- File Syscalls ---Created /test directoryWrote to /test/data.txtRead 21 bytes: Syscall file content
Testing yield (3 times)... Before yield 0 After yield 0 Before yield 1 After yield 1 Before yield 2 After yield 2
=== All Syscall Tests Passed ===踩坑记录
-
系统调用与函数调用的区别是什么?
- 系统调用通过中断触发,需要特权级切换,由内核验证权限
- 函数调用直接跳转,无特权级变化,在同一地址空间内
-
为什么选择 int 0x80 作为系统调用向量?
- 这是 Linux 传统的系统调用入口,兼容性好
- 0x80(128)在 IDT 中间位置,不会与硬件中断冲突
-
系统调用的返回值如何传递?
- 通过修改
interrupt_frame_t中的eax字段 iret指令会恢复所有寄存器,包括修改后的eax
- 通过修改
-
可以传递超过 3 个参数吗?
- 我们的实现仅支持 3 个参数
- 可以通过将参数打包到结构体中传递,或使用额外的寄存器(esi, edi)
-
用户程序如何调用系统调用?
- 在汇编中使用
int 0x80指令 - 在 C 语言中可以编写内联汇编包装函数
- 在汇编中使用
小结
系统调用表将调用号映射到处理函数,寄存器传参约定(eax=调用号,ebx/ecx/edx=参数)简化了参数传递,20 个 POSIX 风格的接口覆盖了文件 I/O、进程控制和系统信息三大类别。这些系统调用是用户程序请求内核服务的唯一合法通道。下一章将基于这些接口实现 Shell——用户与操作系统交互的命令行界面。
系统调用是操作系统的核心接口,它为用户程序提供了一个安全、统一的途径来请求内核服务。理解了系统调用的工作原理,并能够实现基本的系统调用接口。
参考
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






