如何编写一个简单的 Linux 内核模块

抢占 Gloden Ring-0

Linux为应用程序提供了强大且可扩展的API,但有时那并不够。在系统中和硬件交互或者执行需要访问保密信息的操作时需要一个核心模块。

Linux内核是一组编译后的二进制码,它可以直接嵌入 Linux内核,在Ring0上运行,是在x86 - 64处理器中执行的最低和最小保护环。这里的代码完全不受控制,但运行速度惊人而且能够访问系统中的所有内容。

不仅仅是为了人类

编写Linux内核不适合胆小鬼。更改内核,您将可能面临数据丢失和系统损坏的风险。内核编码没有往常的Linux应用程序所习惯的安全保障。如果你有一个错误,他将锁定整个系统。

更糟糕的是,你的问题可能不会显现出来。运行失败的最佳的状况是模块在加载是立刻被锁定。当您向模块中添加更多代码时,您就面临这循环时空和内存泄漏的风险。如果你不仔细,这些风险将随着机器运行而一直增长。最终,重要的内存结构甚至缓冲区都可能被覆盖。

传统的应用程序开发模式将要被大量丢弃。除了模块的加载和卸载之外,您还将编写系统响应事件而不是执行顺序模式运行的代码。随着内核的发展,你将要编写的是APIs,而不是应用程序本身。

您还没有访问标准库的权限。而内核提供的一些功能如printk(作为一种printf的替代)和kmalloc(其工作方式类似于malloc),大部分都由你自己决定。另外,当你的模块被卸载时,您要负责将您自己的模块清理干净。这没有垃圾回收机制。

前提

在我们开始之前,要确保用的是正确的工作工具。更重要的是,你需要一台Linux系统的机器。我知道那完全是一个意外!虽然任意的Linux的版本都可以,在例子中我将要使用Ubuntu 16.04 LTS,如果您使用其他的版本,可能需要稍微调整安装命令。

其次,您需要一个独立的运行环境或者虚拟机。我更偏向在虚拟机中工作,但这完全取决于您。我不建议您使用您当前的机器,因为当你出现错误时数据可能会丢失。我是说什么时候,而不是如果,因为在这个过程中你肯定会锁定您的机器几次。当内核极度繁忙的时候,你的最新代码会存放在缓冲区,因此,您的源文件可能会损坏。而在虚拟机中进行这种测试可以消除这种风险。

最后,你需要知道一点C的知识。C++运行库内核太大,所以写裸机的C是必不可少的。为了与硬件进行交互。了解一些程序集可能会有所帮助。

安装开发环境

在 Ubuntu 中我们需要运行:

apt-get install build-essential linux-headers-`uname -r`

这个命令会安装本示例所需要的开发工具和核心头文件。

下面的示例假定你是一个普通用户而不是 root 用户,但是你有 sudo 权限。sudo 是用于加载核心模型的,但我们希望尽可能地不使用 root 用户。


开始

我们开始写代码之前,先准备环境:

mkdir ~/src/lkm_example
cd ~/src/lkm_example

打开你喜欢的编辑器(我使用 VIM),创建文件 lkm_example.c,输入下面的内容:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE(“GPL”);
MODULE_AUTHOR(“Robert W. Oliver II”);
MODULE_DESCRIPTION(“A simple example Linux module.”);
MODULE_VERSION(“0.01”);
static int __init lkm_example_init(void) {
 printk(KERN_INFO “Hello, World!\n”);
 return 0;
}
static void __exit lkm_example_exit(void) {
 printk(KERN_INFO “Goodbye, World!\n”);
}
module_init(lkm_example_init);
module_exit(lkm_example_exit);

我们已经构造了一个极尽简单的模块,现在我们举例来详细说明各个重要部分:

但是我们还不能编译这个文件。我们需要制造一个文件。这个基础实例现在要起作用了。注意,make对于空格键和 tab 键是非常苛刻的,所以要保证的适当的地方用 tabs 键代替 Tab 键。

obj-m += lkm_example.o

all:
 make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
 make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

当你运行"make"时,它应该可以成功的编译你的模块。这个文件的编译结果是“lkm_example.ko”。如果你发现任何错误,请检查你的示例文件中的引用是否是正确的,并且检查有没有不小心粘贴了UTF-8的字符。

现在我们来添加这个模块进行测试。运行操作:

sudo insmod lkm_example.ko

如果运行成功,你还看不到任何结果。printk 函数不会输出在控制台而是输出在内核日志。为了看到日志,我们运行一下命令:

sudo dmesg

你将会看到“Hello,World!”前边有一个时间戳。这意味着内核模块加载并成功的将结果打印在内核日志。我们还可以检查模块是否仍然被加载:

lsmod | grep “lkm_example”

要想移除该模块,运行一下命令:

sudo rmmod lkm_example

如果你再一次运行dmesg,你将会在日志中看到“Goodbye,World!”。你可以再一次运行lsmod来确认模块是否已卸载。

正如你所看到,这个测试工作流程有点乏味,为了让其进行自动化测试,在Makefile结尾添加以下代码:

test:
 sudo dmesg -C
 sudo insmod lkm_example.ko
 sudo rmmod lkm_example.ko
 dmesg

然后运行以下命令:

make test

不必再运行单行命令来测试我们的模块和在内核日志看输出。

现在我们有了一个功能全面但又非常琐碎的内核模块!

更有趣一点

让我们深入一点。 虽然内核模块可以完成各种任务,但与应用程序交互是最常见的用途之一。

由于应用程序受限于查看内核空间内存的内容,因此应用程序必须使用API与其通信。 虽然在技术上有多种方法来实现这一点,但最常见的是创建一个设备文件。

您之前可能与设备文件进行了交互。 使用 /dev/zero/dev/null或类似命令的命令与名为“zero”和“null”的设备进行交互,以返回期望的值。

在我们的例子中,我们将返回“Hello, World”。 虽然这不是提供应用程序的特别有用的功能,但它将显示通过设备文件,响应应用程序的过程

完成的代码如下:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <asm/uaccess.h>
MODULE_LICENSE(“GPL”);
MODULE_AUTHOR(“Robert W. Oliver II”);
MODULE_DESCRIPTION(“A simple example Linux module.”);
MODULE_VERSION(“0.01”);
#define DEVICE_NAME “lkm_example”
#define EXAMPLE_MSG “Hello, World!\n”
#define MSG_BUFFER_LEN 15
/* Prototypes for device functions */
static int device_open(struct inode *, struct file *);
static int device_release(struct inode *, struct file *);
static ssize_t device_read(struct file *, char *, size_t, loff_t *);
static ssize_t device_write(struct file *, const char *, size_t, loff_t *);
static int major_num;
static int device_open_count = 0;
static char msg_buffer[MSG_BUFFER_LEN];
static char *msg_ptr;
/* This structure points to all of the device functions */
static struct file_operations file_ops = {
 .read = device_read,
 .write = device_write,
 .open = device_open,
 .release = device_release
};
/* When a process reads from our device, this gets called. */
static ssize_t device_read(struct file *flip, char *buffer, size_t len, loff_t *offset) {
 int bytes_read = 0;
 /* If we’re at the end, loop back to the beginning */
 if (*msg_ptr == 0) {
 msg_ptr = msg_buffer;
 }
 /* Put data in the buffer */
 while (len && *msg_ptr) {
 /* Buffer is in user data, not kernel, so you can’t just reference
 * with a pointer. The function put_user handles this for us */
 put_user(*(msg_ptr++), buffer++);
 len--;
 bytes_read++;
 }
 return bytes_read;
}
/* Called when a process tries to write to our device */
static ssize_t device_write(struct file *flip, const char *buffer, size_t len, loff_t *offset) {
 /* This is a read-only device */
 printk(KERN_ALERT “This operation is not supported.\n”);
 return -EINVAL;
}
/* Called when a process opens our device */
static int device_open(struct inode *inode, struct file *file) {
 /* If device is open, return busy */
 if (device_open_count) {
 return -EBUSY;
 }
 device_open_count++;
 try_module_get(THIS_MODULE);
 return 0;
}
/* Called when a process closes our device */
static int device_release(struct inode *inode, struct file *file) {
 /* Decrement the open counter and usage count. Without this, the module would not unload. */
 device_open_count--;
 module_put(THIS_MODULE);
 return 0;
}
static int __init lkm_example_init(void) {
 /* Fill buffer with our message */
 strncpy(msg_buffer, EXAMPLE_MSG, MSG_BUFFER_LEN);
 /* Set the msg_ptr to the buffer */
 msg_ptr = msg_buffer;
 /* Try to register character device */
 major_num = register_chrdev(0, “lkm_example”, &file_ops);
 if (major_num < 0) {
 printk(KERN_ALERT “Could not register device: %d\n”, major_num);
 return major_num;
 } else {
 printk(KERN_INFO “lkm_example module loaded with device major number %d\n”, major_num);
 return 0;
 }
}
static void __exit lkm_example_exit(void) {
 /* Remember — we have to clean up after ourselves. Unregister the character device. */
 unregister_chrdev(major_num, DEVICE_NAME);
 printk(KERN_INFO “Goodbye, World!\n”);
}
/* Register module functions */
module_init(lkm_example_init);
module_exit(lkm_example_exit);

测试改进的示例

现在我们的示例并不只是在加载和卸载的时候打印一条消息,我们需要一个限制较少的测试用例。来修改 Makefile,让它只加载,不卸载模块。

obj-m += lkm_example.o
all:
  make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
  make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
test:
  # We put a — in front of the rmmod command to tell make to ignore
  # an error in case the module isn’t loaded.
  -sudo rmmod lkm_example
  # Clear the kernel log without echo
  sudo dmesg -C
  # Insert the module
  sudo insmod lkm_example.ko
  # Display the kernel log
  dmesg

这时候如果你运行“make test”,你会看到输出设备的主要编号。在我们的示例中,它是由内核自动分配的。不过你需要这个值来创建设备。

拿到通过“make test”获得的值之后,使用它创建一个设备文件,这样我们就可以在用户空间与我们的核心模块进行通信。

sudo mknod /dev/lkm_example c MAJOR 0

(在上面的示例中,将 MAJOR 换成你从 “make test” 或 “dmesg” 获得的值)

mknod 命令中的 “c” 会告诉 mknod 我们需要创建一个字符设备文件。

现在我们可以从设备中获取内容:

cat /dev/lkm_example

或者通过“dd”命令:

dd if=/dev/lkm_example of=test bs=14 count=100

您也可以通过应用程序访问此设备。 他们不需要编译应用程序 - 甚至PythonRubyPHP脚本也可以访问这些数据。

当我们完成设备时,删除它并卸载模块:

sudo rm /dev/lkm_example
sudo rmmod lkm_example


总结

我希望你喜欢我们在内核土地玩耍。 虽然我提供的例子是基本的,但是可以使用这个结构来构建自己的模块,它可以完成非常复杂的任务。

只要记住,你完全是在自己的领域。 你的代码没有后援或第二次机会。 如果您正在为客户端引用一个项目,请务必将预期的调试时间加倍(如果不是三倍)。 内核代码必须尽可能完美,以确保系统运行的完整性和可靠性。