本篇是《Linux内核学习笔记》系列的第三篇。
应用程序的权力可以说是相当受限,它只能执行简单的算术运算,以及非常局限的地址跳转和内存访问。好在内核会向应用程序开放一些接口,即系统调用。它是操作系统中最为重要的概念之一。
应用程序通过系统调用发出请求,例如申请资源、创建文件句柄等。内核会处理这些请求,并判断是否真的可以满足请求。系统调用保证了系统的稳定性和可靠性,避免用户进程肆意妄为。
1. 概述
1.1. 系统调用
系统调用封装在C库的API中,例如getpid,它返回线程组的ID(实际上不是进程ID!Linux内核中线程也表示为进程,但一个进程的多个线程隶属于同一线程组。要得到真实的进程ID,使用gettid)。让我们来看看它在内核中的形态:
// In kernel/sys.c
SYSCALL_DEFINE0(getpid)
{
return task_tgid_vnr(current);
}
Linux 5.15中, SYSCALL_DEFINE0(getpid) 展开后部分内容如下:
// omit some content here
static long __do_sys_getpid(const struct pt_regs *__unused)
{
return task_tgid_vnr(current);
}
除此之外,还有很多的系统调用。以x86为例:
// In arch/x86/um/sys_call_table_32.c
const sys_call_ptr_t sys_call_table[] ____cacheline_aligned = {
#include <asm/syscalls_32.h>
};
#include的内容如下,我们可以看到每一个系统调用及其对应的系统调用号:
__SYSCALL(0, sys_restart_syscall)
__SYSCALL(1, sys_exit)
__SYSCALL(2, sys_fork)
__SYSCALL(3, sys_read)
__SYSCALL(4, sys_write)
__SYSCALL_WITH_COMPAT(5, sys_open, compat_sys_open)
__SYSCALL(6, sys_close)
__SYSCALL(7, sys_waitpid)
// ...
该表的内容是从另外一个文本文件中自动生成的。
# In arch/x86/entry/syscalls/syscall_32.tbl
#
# 32-bit system call numbers and entry vectors
#
# The format is:
# <number> <abi> <name> <entry point> <compat entry point>
#
# The __ia32_sys and __ia32_compat_sys stubs are created on-the-fly for
# sys_*() system calls and compat_sys_*() compat system calls if
# IA32_EMULATION is defined, and expect struct pt_regs *regs as their only
# parameter.
#
# The abi is always "i386" for this file.
#
0 i386 restart_syscall sys_restart_syscall
1 i386 exit sys_exit
2 i386 fork sys_fork
3 i386 read sys_read
4 i386 write sys_write
5 i386 open sys_open compat_sys_open
6 i386 close sys_close
7 i386 waitpid sys_waitpid
# ...
这里先留下一个问题:系统调用看起来是体系结构相关的,但系统调用的实现却可以是体系结构无关的,这是为什么?
1.2. 系统调用流程
用户发起系统调用的流程如下:
- 用户使用系统调用封装函数,该函数先将必要的信息,如系统调用号、参数等存入寄存器,随后调用汇编指令
int 0x80。不过,现在的CPU可以支持更加高效的sysenter指令。 - 该指令触发异常,交由第0x80号异常处理程序,即系统调用处理程序来处理。
- 系统调用处理程序判断系统调用号的合法性,随后交由对应的例程处理。
2. 系统调用的实现
2.1. 接口设计
机制:系统调用必须保证语义严谨、向后兼容,而许多系统调用还保有向前兼容。同时,也需要考虑系统调用在不同体系结构下的可移植性,所以一定要小心谨慎,避免对接口功能作出错误的假设。
策略:当接口设计好后,往内核真实地添加一个系统调用并不是一件难事。这时我们需要在不同体系结构下具体地实现它。
这就是著名的“策略与机制分离”。
2.2. 参数验证
用户程序通过系统调用向内核传入参数。内核必须仔细检查这些参数,确保用户程序不逾矩,例如检查用户传入的地址是合法的、位于自己进程空间的读写地址。
有时用户程序调用的是自己无权执行的系统调用,例如非超级用户模式下调用reboot。内核需要确认用户进程是否拥有该权限。所有权限及其对应编号位于include/uapi/linux/capability.h中。
3. 亲手为内核添加一个系统调用
笔者使用WSL2的内核源代码进行操作,写下本篇文章时为5.10版本。
微软官方源代码地址:https://github.com/microsoft/WSL2-Linux-Kernel
笔者自己玩的内核repo:https://github.com/Bug-001/WSL2-Linux-Kernel
3.1. 修改内核
加一个系统调用和将大象装进冰箱一样简单,只需要两步。
第一步是在arch/x86/entry/syscalls/syscall_xx.tbl中添加表项,如下:
# In arch/x86/entry/syscalls/syscall_32.tbl
# ...
439 i386 faccessat2 sys_faccessat2
440 i386 process_madvise sys_process_madvise
# add an entry here
441 i386 homo_114514 sys_homo_114514
在syscall_64.tbl中也需要添加,从而确保内核对32位和64位系统调用都能正确支持。
第二步是添加系统调用代码。系统调用必须在内核映像中添加,这里添加到kernel/sys.c的末尾。
SYSCALL_DEFINE1(homo_114514, char *, str)
{
const char * saying = "yarimasune!";
if (copy_to_user(str, saying, strlen(saying) + 1))
return -EFAULT;
return 0;
}
这是一个特别简单的系统调用,只是将一个字符串从内核返回,保存到用户指定的地址中。这么臭的字符串有必要让内核专门开辟一个系统调用号提供吗(恼
copy_to_user是内核提供的拷贝函数,可以对地址做所有的必要检查,并将数据从内核空间拷贝到用户空间。
3.2. 编写测试代码
不废话,直接贴代码:
#include <stdio.h>
#include <unistd.h>
#define __NR_homo_114514 441
int main() {
char str[20] = {0};
long result = syscall(__NR_homo_114514, str);
if (result != 0)
perror("BAD TRAP");
else
printf("GOOD TRAP: %s\n", str);
return 0;
}
由于没有人会愿意为自己做着玩的臭系统调用专门封装一个函数出来,我们只能用通用的syscall函数,通过系统调用号直接操作。
效果图如下:
