mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2041 字
5 分钟
系统调用:20 个 POSIX 接口
2021-10-17

用户程序不能直接调用内核函数——那是特权级隔离的基本规则。那怎么请求内核服务?系统调用是用户空间和内核空间之间唯一的合法通道。上一章实现了 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);
}

系统调用处理器的执行流程如下:

graph TD A[用户程序执行 int 0x80] --> B[CPU 保存上下文] B --> C[切换到内核态] C --> D[中断处理程序] D --> E[解析 eax=调用号] E --> F[从 syscall_table 查找函数] F --> G[执行内核函数] G --> H[返回值写入 eax] H --> I[恢复用户上下文] I --> J[返回用户态]

参数传递机制#

系统调用需要在用户空间和内核空间之间传递参数。由于系统调用是通过中断触发的,不能使用常规的函数调用约定。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 个系统调用可以按照功能分为以下几类:

mindmap root((系统调用)) 进程控制 exit fork exec wait getpid gettid sleep yield 文件操作 open close read write mkdir readdir getcwd chdir I/O 操作 puts getchar 系统信息 uptime ps

代码实现#

文件结构#

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 返回给用户程序
  • 中断返回时会恢复这个栈帧,回到用户态继续执行

系统调用的完整执行流程:

flowchart TD A[用户程序<br/>mov eax, SYS_READ<br/>mov ebx, fd<br/>mov ecx, buf<br/>mov edx, count<br/>int 0x80] --> B[中断入口<br/>isr_common_stub<br/>保存所有寄存器] B --> C[调用C层处理<br/>isr_handler] C --> D{中断类型判断} D -->|中断号=0x80| E[系统调用处理<br/>syscall_handler] D -->|其他中断| F[其他中断处理程序] E --> G[从 eax 读取调用号] G --> H[从 syscall_table 查找函数] H --> I[调用内核函数<br/>如 sys_read] I --> J[内核函数执行<br/>访问硬件资源] J --> K[返回值写入 eax] K --> L[恢复所有寄存器] L --> M[iret 返回用户态] M --> N[用户程序继续执行<br/>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);
}

解析

  • getpidgettid 从当前进程/线程结构中获取 ID
  • forkexecwait 是复杂的进程管理操作,在 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-syscall
make 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() = 1
sys_gettid() = 1
sys_uptime() = 0 seconds
sys_puts(): Hello from syscall!
sys_write(1, ...): works!
--- File Syscalls ---
Created /test directory
Wrote to /test/data.txt
Read 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 ===

踩坑记录#

  1. 系统调用与函数调用的区别是什么?

    • 系统调用通过中断触发,需要特权级切换,由内核验证权限
    • 函数调用直接跳转,无特权级变化,在同一地址空间内
  2. 为什么选择 int 0x80 作为系统调用向量?

    • 这是 Linux 传统的系统调用入口,兼容性好
    • 0x80(128)在 IDT 中间位置,不会与硬件中断冲突
  3. 系统调用的返回值如何传递?

    • 通过修改 interrupt_frame_t 中的 eax 字段
    • iret 指令会恢复所有寄存器,包括修改后的 eax
  4. 可以传递超过 3 个参数吗?

    • 我们的实现仅支持 3 个参数
    • 可以通过将参数打包到结构体中传递,或使用额外的寄存器(esi, edi)
  5. 用户程序如何调用系统调用?

    • 在汇编中使用 int 0x80 指令
    • 在 C 语言中可以编写内联汇编包装函数

小结#

系统调用表将调用号映射到处理函数,寄存器传参约定(eax=调用号,ebx/ecx/edx=参数)简化了参数传递,20 个 POSIX 风格的接口覆盖了文件 I/O、进程控制和系统信息三大类别。这些系统调用是用户程序请求内核服务的唯一合法通道。下一章将基于这些接口实现 Shell——用户与操作系统交互的命令行界面。

系统调用是操作系统的核心接口,它为用户程序提供了一个安全、统一的途径来请求内核服务。理解了系统调用的工作原理,并能够实现基本的系统调用接口。

参考#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

系统调用:20 个 POSIX 接口
https://blog.souloss.com/posts/os/syscall-posix-interfaces/
作者
Souloss
发布于
2021-10-17
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时