为Linux添加一个系统调用

在之前的博客中,我使用 C 和 Python 分别实现了一个简单的 Shell,这是一个很有意思的小程序,可以让你了解你每天都在使用的工具。而在简单的 Shell 之下则是一系列的系统调用,例如:read, fork, exec, wait, write等等。现在让我们继续这段旅程,开始学习这些系统调用是如何在 Linux 中实现的。

什么是系统调用

在我们实现系统调用之前,我们最好弄清楚它们是什么。一个菜鸟程序员——像几年前的我——可能会将系统调用定义为 C 标准库提供的任何函数。但这并不完全准确。即使许多 C 标准库中的函数与系统调用对应的很好(例如 chdir),但大部分都会比简单地使用系统调用做更多的事(例如 fork, fprintf)。或者纯粹通过编程实现不使用系统调用(qsort, strtok)。

事实上,一个系统调用有着非常明确的定义。它是一种按照你的需求调用操作系统内核资源的方式。对字符串进行分词这样的操作不需要和内核交互,但是任何涉及到设备,文件或者进程的操作需要。

系统调用在底层的行为也与普通函数不同。不同于简单地让指令指针跳转到你的程序或函数库的代码段,而是让你的程序请求 CPU 切换到内核模式,然后访问预先在内核中定义的地址来执行系统调用。这只能通过几种方式实现,例如:处理器中断或者特定的指令(syscall,sysenter) 。

难得的是,实现系统调用的最复杂的部分已经在内核中实现了。不管一个系统调用是如何实现的,都得通过系统调用号查表来找到并调用正确的内核函数,这对于实现我们自己的系统调用来说是非常简单的。让我们开始吧。

设置虚拟机

实现系统调用并不是通过修改某一个内核模块来实现。而是你必须获得一份 Linux 源码的复制,修改它,编译它,启动它。这些都是可以直接在你的主机中完成的事(如果主机是 Linux系统),但最好在虚拟机上进行这类尝试。例如在 VirtualBox 上。

虽然你可以手动地配置一台虚拟机,但更节省时间的方式是下载一台已经配置好的。你可以通过这个链接 下载一台已经配置好的虚拟机。在这篇文章中,我会使用201608CLI 版本的 VirtualBox 虚拟机。下载并解压它。在 VirtualBox 中创建一台新的虚拟机,选择下载解压好的 vdi 文件作为硬盘文件。创建并允许你的虚拟机,你就能看到你的命令行登录界面了。根用户的密码可以在下载页面查看到(我的是:osboxes.org)。

注意:如果你有一台多核的机器,编辑你的虚拟机设置让它使用多核将会是一个很好的主意。这将会显著地提升编译效率,只要你再接下来的命令中使用 make -jN 替换 make,其中 N 是你机器的 CPU 核数。

需要做的第一步是安装 bc,一个没有包含进虚拟机的编译期 Linux 依赖。不幸的是,这需要你首先更新虚拟机。注意我在这篇博客中给出的每一条命令都需要在 root 下运行。

1
2
3
$ pacman -Syu
$ pacman -S bc
$ reboot

你需要重启虚拟机,因为内核将被更新。我们必须确保我们运行的内核是更新过的以开始后续的操作。

获取源码

在你配置好虚拟机后,下一步是下载内核源码。尽管大部分开发者本能地想到用 Git 来获取他们需要的代码,但这一次可能并不需要。Linux 的 Git 仓库太大了,所以复制它是很不值得的。然而可以你可以下载内核版本相关的源码压缩文件。你可以通过 uname -r 来查看内核版本。然后在 kernel.org 上下载最接近的版本。在你的虚拟机中,使用 curl 命令来下载源码:

1
2
# -O -J will set the output filename based on the URL
$ curl -O -J https://www.kernel.org/pub/linux/kernel/v4.x/linux-VERSION.tar.xz

之后解压 tarball 文件:

1
2
$ tar xvf linux-VERSION.tar.xz
$ cd linux-VERSION

配置你的内核

Linux 内核是非常方便配置的。你可以启用或废弃它的大部分功能。如果你要手动地配置每一个选项,你将会做一整天。幸运地使,你可以通过复制你的内核已有的配置文件来跳过这一步,它保存在/proc/config.gz 。通过这条命令来将配置应用在你的新内核中:

1
$ zcat /proc/config.gz > .config

为了确保配置文件中的所有变量都被赋值,运行 make oldconfig。如果没有问题,命令行将不会询问你任何配置问题。

你唯一需要修改的配置项是你的内核名,确保它不会和你当前已经安装的发生冲突。在 Arch Linux 中,内核在构建时就带有后缀 -ARCH。你应该改变这个后缀,通过文本编辑器打开.confit,然后直接修改它的行。你将会发现它就在”General setup”头下面,距文件底不远。

1
CONFIG_LOCALVERSION="-ARCH"

添加你的系统调用

现在内核已经配置好了,你可以马上开始编译它了。然而创建一个系统调用需要编辑系统调用表,它记录了每一条系统调用的大致信息。因为编译需要花费很长的时间,你现在编译的话会浪费很多的时间。不如让我们做一些有价值的事情吧:开始写你自己的系统调用!

Linux 中的一些代码是架构特定的,例如一些初始化处理中断和系统调用的代码。因此系统调用表所在的目录取决于你的处理器架构。我们将在 x86_64 架构的机器上操作。

系统调用表

在 x86_64 的机器上包含系统调用表的文件在路径arch/x86/entry/syscalls/syscall_64.tbl 。这张表通过脚本读入并生成一些模板代码,这将使我们的事情变得非常简单。来到文件底部(在4.7.1版本中系统调用号结束于328),添加下面这行:

1
329	common	yifeng	sys_yifeng

注意每一列之间是一个 tab(不是空格)。第一列是系统调用号。在这张表中我选择了下一个可用的数字——329.在不同版本的系统中这个数字可能不同!第二列表明这个系统调用号是在32位和64位 CPU 中共用的。第三列是系统调用名,第四列是实现函数名。转换系统调用名到实现函数名是简单的,在系统调用名前加上前缀sys_ 即可。我使用了 yifeng 作为我的系统调用名,你也可以使用任何你喜欢的。

系统调用函数

最后一步是为系统调用实现调用函数。我们其实并不知道系统调用应该做些什么,但我们想让它做一些简单的我们可以察觉的事。比如使用printk() 打印一些内核日志。所以,我们的系统调用将会需要传入一个参数(一个字符串),然后将它写入内核日志。

你可以在任何地方实现系统调用,但是杂项的系统调用最好写在kernel/sys.c 文件中。

1
2
3
4
5
6
7
8
9
SYSCALL_DEFINE1(yifeng, char *, msg)
{
char buf[256];
long copied = strncpy_from_user(buf, msg, sizeof(buf));
if (copied < 0 || copied == sizeof(buf))
return -EFAULT;
printk(KERN_INFO "yifeng syscall called with \"%s\"\n", buf);
return 0;
}

SYSCALL_DEFINEN 是一系列的宏定义族,它使得定义 N 个变量的系统调用变得简单。宏的第一个参数是系统调用的名字(没有 sys_ 前缀)。接下来的参数是系统调用的参数类型和参数名对。因为我们的系统调用只有一个参数,所以我们使用 SYSCALL_DEFINE1 ,我们的唯一参数是char * 和参数名msg

我们马上会遇到一个有趣的问题,那就是我们无法直接使用提供给我们的 msg 的指针。有以下几点可能的原因:

  • 进程可能会尝试愚弄我们来打印内核空间的数据。通过传递一个映射到内核空间的指针,这个系统调用将会打印内核空间的数据。这将不能被允许。
  • 进程可能会尝试度其他进程的内存,通过传递一个映射到其他内存地址空间的指针。
  • 我们需要考虑内存的 读/写/执行 的权限。

为了处理这个问题,我们使用了strncpy_from_user() 函数,它的行为和strncpy 类似,但是会首先检查传入的地址是否是来自用户空间的地址。如果传入的字符串太长或者在复制的时候出现了问题,我们都会返回 EFAULT (即使返回EINVAL 对于字符串太长的情况更合适)。

最终我们使用printk 来打印。KERN_INFO 会展开成一个文本字符串。编译器将会把它和格式化字符串串联起来,打印的效果和printf 类似。

编译和启动内核

注意:这些步骤会有一些复杂。看这部分的时候,你不需要一步一步输入命令,在最后我会给你一个完成这些工作的脚本。

我们的第一步是编译内核和它的模块。内核的主镜像通过make 编译。你可以在下面的文件中看到编译结果:arch/x86_64/boot/bzImage 。在运行make modules_install 后, 这个版本的内核模块将被编译并复制到 /lib/modules/KERNEL_VERSION 。例如,按照我所给出的配置,这些模块将被编译并放置在/lib/modules/linux-4.7.1-yifeng/

在你已经编译好内核及其模块后,你将需要做一些事情来启动它。首先,你将要复制你已经编译好的内核镜像到你的/boot 目录:

1
$ cp arch/x86_64/boot/bzImage /boot/vmlinuz-linux-yifeng

接下来需要创建一个“initramfs”。

通过两步来实现它。首先基于你之前的文件创建一个 preset:

1
$ sed s/linux/linux-yifeng/g </etc/mknitcpio.d/linux.preset >/etc/mkinitcpio.d/linux-yifeng.preset

之后生成一个镜像:

1
$ mkinitcpio -p linux-yifeng

最后你需要指导你的 bootloader (这里是指我们的虚拟机,GRUB)来启动我们的新内核。因为 GRUB 可以自动找到 /boot 目录下的内核镜像,所有我们需要做的是再生成 GRUB 配置:

1
$ grub-mkconfig -o /boot/grub/grub.cfg

所以上面的操作可以总在一个脚本中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/usr/bin/bash
# Compile and "deploy" a new custom kernel from source on Arch Linux

# Change this if you'd like. It has no relation
# to the suffix set in the kernel config.
SUFFIX="-yifeng"

# This causes the script to exit if an error occurs
set -e

# Compile the kernel
make
# Compile and install modules
make modules_install

# Install kernel image
cp arch/x86_64/boot/bzImage /boot/vmlinuz-linux$SUFFIX

# Create preset and build initramfs
sed s/linux/linux$SUFFIX/g \
</etc/mkinitcpio.d/linux.preset \
>/etc/mkinitcpio.d/linux$SUFFIX.preset
mkinitcpio -p linux$SUFFIX

# Update bootloader entries with new kernels.
grub-mkconfig -o /boot/grub/grub.cfg

deploy.sh 保存在你内核源码的主目录下,执行chmod u+x deploy.sh 来设置执行权限。现在你就可以通过运行这个脚本来构建和部署内核了。编译将会需要一些时间。

一旦脚本执行完成,执行reboot 。当 GRUB 弹出,选择“Advanced Options for Arch Linux”。这将会显示可用内核的菜单列表,选择你自定义的内核并启动它。

如果一切顺利,就能看到带有自定义内核版本的登录页面了。

测试你的系统调用

至此,我们已经编译并启动了我们自定义并修改的内核了。接下来就是最激动人心的时刻了——运行我们的系统调用。

C 标准库为我们封装了大部分系统调用,但我们没有考虑过如何触发一个中断。对于无法直接调用的系统调用,GNC C标准库提供了一个 syscall() ,它可以根据系统调用号来调用系统调用。

下面是一个小程序,使用系统调用号来调用我们自定义的系统调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
* Test the yifeng syscall (#329)
*/
#define _GNU_SOURCE
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>

/*
* Put your syscall number here.
*/
#define SYS_yifeng 329

int main(int argc, char **argv)
{
if (argc <= 1) {
printf("Must provide a string to give to system call.\n");
return -1;
}
char *arg = argv[1];
printf("Making system call with \"%s\".\n", arg);
long res = syscall(SYS_yifeng, arg);
printf("System call returned %ld.\n", res);
return res;
}

将这个文件命名为test.c ,编译它gcc -o test test.c 。接下来就可以试着调用系统调用了,尝试打印“Hello World”。

1
2
$ ./test 'Hello World!'
# use single quotes if you have an exclamation point :)

可以通过dmesg 指令查看生成的日志。因为dmesg会在你的终端弹出超多的信息,所以使用dmesg|tail 来查看日志的最后几行就可以了。你就可与在日志里看到系统调用生成的文本了。

本博客参考了该链接

# Linux

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×