Linux Kernel添加syscall

 

部门的培训课程有一个实验,要求是这样的:在内核中增加一个系统调用,获取系统启动以来,经过了多少 tick(jiffies),根据内核配置换算成毫秒

环境为ARM64 + Linux 5.14-rc4(如果之前是按照教程用git clone下载了最新版本的内核代码,那么版本就是一样的,无须担心;你也可以查看内核代码顶层目录下的Makefile的前几行判断内核版本)。不同的架构以及内核版本,过程不一定完全相同,请注意。

本文分成三个部分,分别对应该实验的三个步骤:添加系统调用编译与文件传输patch提交

添加系统调用

首先要明确,添加一个系统调用可以分为三个部分:添加系统调用号添加函数声明函数实现

添加系统调用号

内核代码中有关于添加系统调用的文档(./Documentation/process/adding-syscalls.rst),其中Generic System Call Implementation一节有提到(下文中的xyzzy是添加的系统调用名):

Some architectures (e.g. x86) have their own architecture-specific syscall tables, but several other architectures share a generic syscall table. Add your new syscall to the generic list by adding an entry to the list in include/uapi/asm-generic/unistd.h::

#define __NR_xyzzy 292
__SYSCALL(__NR_xyzzy, sys_xyzzy)

Also update the __NR_syscalls count to reflect the additional system call……

所以修改include/uapi/asm-generic/unistd.h(其实我也不知道ARM64是否属于上文提到的“other architectures”,但是这么做管用:)。如果有了解这方面的小伙伴,请指教!):

一直往下翻,翻到最大的系统调用号:

...
#define __NR_landlock_restrict_self 446
__SYSCALL(__NR_landlock_restrict_self, sys_landlock_restrict_self)

#ifdef __ARCH_WANT_MEMFD_SECRET
#define __NR_memfd_secret 447
__SYSCALL(__NR_memfd_secret, sys_memfd_secret)
#endif

#undef __NR_syscalls
#define __NR_syscalls 448
...

当前最大的系统调用号是447,对应sys_memfd_secret这个系统调用。而__NR_syscalls比最大的系统调用号大1,可以看做是系统调用的数量。我们要添加系统调用号,也要根据这样的格式,然后更新__NR_syscalls,让它比我们添加的系统调用号大1,具体过程就不再赘述。

函数声明

之后的步骤就与硬件平台无关了。系统调用的声明都在./include/linux/syscalls.h中添加,随便找个位置:

asmlinkage long sys_getms_sinceboot(unsigned int __user *msecs);

系统调用的格式都是sys_xxxxxxxx为该系统调用的名字,这里getms_sinceboot就是我们新增系统调用的名字。

函数签名前面有asmlinkage字段,它是一个gcc标签,代表函数读取的参数来自于栈,而非寄存器。

参数中的__user字段表示该参数来自用户空间。执行系统调用的时候系统处于内核空间,而内核空间和用户空间所处的内存地址是不一样的,需要通过copy_to_user()将内核空间的数据拷贝到用户空间。

ps:只有参数是指针类型的时候需要加__user,因为指针类型存储的是地址;

pps:我建议用指针获得返回地址,在需要两个或者更多返回值的情况下,指针的优势就凸显出来了。

这里用msecs这个参数来获取最终结果,我们在用户空间写测试程序的时候,将msecs指针传进系统调用,系统调用经过一系列操作,将数据通过copy_to_user()传递给msecs,最后将msecs打印出来即可得到最终结果。

函数实现

函数实现会根据函数的功能分类,分布在不同的.c文件里。在./include/linux/syscalls.h中,函数实现在同一个.c文件的函数声明是放在一起的,这里我们随便挑一个文件./kernel/signal.c(自己新建一个.c文件添加函数实现也行,但是要把这个文件放进Makefile里,有些麻烦),添加这样一段代码:

/*
 * sys_getms_sinceboot - get how many milliseconds have elapsed since boot
 * @msecs: return the value of msecs corresponding to jiffies
 */
#include <linux/jiffies.h>
SYSCALL_DEFINE1(getms_sinceboot, unsigned int __user *, msecs)
{
        unsigned long kjiffies = get_jiffies_64() - INITIAL_JIFFIES;
        unsigned int kmsecs = jiffies_to_msecs(kjiffies);
        copy_to_user(msecs, &kmsecs, sizeof(kmsecs));
        return 0;
}

这些实现都是通过/include/linux/syscall.h中的SYSCALL_DEFINEx(x可以取0-6)宏来定义的,x为该系统调用的参数数量,SYSCALL_DEFINEx的第一个参数为系统调用名,后面分别是参数类型、参数名。

获取jiffies可以用linux/jiffies.h提供的接口get_jiffies_64()jiffies_64是64位,关于jiffies请各位自行搜索)。

因为jiffies的初始值并非0,所以需要减去一个初始值,linux/jiffies.h中提供了这个初始值:INITIAL_JIFFIES

完成以上步骤后,编译即可。

编译与文件传输

内核的编译这里就不再赘述,参考:Qemu虚拟机运行ARM64架构的Linux。

这里说一下测试程序的编译——之前为了编译内核,已经安装过了。用法和gcc相同:

aarch64-linux-gnu-gcc file.c -o file

接下来将文件传输到qemu虚拟机中,首先开启qemu虚拟机。

用ssh连接到Debian,输入命令:

sftp -P 10023 root@localhost

在sftp连接完成之后,输入任何命令都相当于在qemu虚拟机中执行,如果你想在Debian执行命令,就在命令前面加上l,如lls

然后用put命令将编译好的可执行文件传到qemu虚拟机中(注意Debian当前所在路径),之后执行即可。

patch提交

首先确定你的内核代码里是否有.git目录(用ls -a查看),如果没有,在对内核进行修改之前,在内核代码目录下git init也可以。然后用git branch查看当前所在分支,一般情况下只有master分支。我们先创建一个自己的分支,方便之后生成patch,也方便之后其他功能的开发:

git branch <your_branch_name>

就生成了一个新分支,然后要切换到这个分支:

git checkout <your_branch_name>

对内核的修改结束之后,cd到顶层目录,先将代码提交,步骤如下:

git add .                                            # .表示当前目录下的所有文件,这个命令是将当前目录下所有文件的修改放到暂存区
git commit -m "<your_commit_message>"                # 将暂存区的所有修改提交到版本库
git format-patch HEAD~1                              # 生成patch,HEAD始终指向当前最新的commit,HEAD~x表示以HEAD之前第x个commit为基准,将之后的所有修改生成patch

随后就得到了patch文件。