想要开始黑掉核?没有线索不知道如何开始?让我们向你展示如何做…
内核编程通常被视为黑魔法。在Arthur C Clarke的意义上说,它可能是。Linux内核与用户空间有很大的不同:抛开漫不经心的态度,你要格外小心,因为在你代码中的一个小小的bug都会影响整个系统。这里没有简单的方法来做浮点运算、堆栈既固定又小,你写的代码总是异步所以你需要考虑并发性。尽管如此,Linux内核是一个非常大而复杂的C程序,对每个人都是开放的(阅读、学习、提高),而你也可以成为其中的一部分。
“最简单的方法开始内核编程是编写一个模块—— 一段可以动态地加载到内核的代码。”
开始内核编程的最简单方法可能是编写一个模块—— 一段代码,可以动态地在内核中加载和删除。对于模块而言也是有限制的——例如,他们不能添加或删除进程描述符等常用数据结构的字段。但在其他方面他们是成熟的内核级代码,如果需要的话,他们总是可以被编译到内核(因此可以绕开了所有的限制)。完全可以开发和编译Linux源代码树之外的模块(这是意料之中的是称为一个树的构建),这是非常方便的,如果你只是想玩一下,不想提交你的更改包含到主线内核。
在本教程中,我们将开发一个简单的内核模块,创建一个/dev/reverse设备。一个字符串读取写入该设备用词序颠倒(“Hello World”变成“World Hello”)。这是个很受欢迎的程序员面试难题,你可能会得到一些加分当你通过自己的能力在内核级别实现它时。有几句警告要在开始前告诉你:模块中的错误可能导致系统崩溃和数据丢失(不太可能,但是可能)。确保你备份你所有重要的数据在你开始之前,或者,更好的是,在一个虚拟机上实验。
尽可能避免root权限
默认情况下,/dev/reverse是属于root的,所以你必须用sudo运行你的测试程序。为了解决这个问题,创建一个/lib/udev/rules.d/99-reverse.rules文件,写入:
SUBSYSTEM=="misc", KERNEL=="reverse", MODE="0666"
别忘了重新插入模块。使设备节点可访问到非root用户通常不是一个好主意,但它在开发过程中非常有用。更不用说,作为根用户运行测试二进制文件不是一个好主意。
模块的结构
大多数Linux内核模块是用C编写的(除了底层特定于体系结构的部分),建议你保持你的模块在一个文件中(reverse.c)。我们把完整的源代码在GitHub(我把源代码注释上传了,免费下载查看),下面我们来看看一些片段。首先,我们包括一些常见的标题和描述该模块使用预定义的宏:
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Valentine Sinitsyn <[email protected]>");
MODULE_DESCRIPTION("In-kernel phrase reverser");
在这里一切都很简单,除了MODULE_LICENSE():它不是一个纯粹的标志。内核强烈赞成GPL-compatible代码,所以如果你设置一些非GPL-compatible(比如“Proprietary”),某些内核模块功能将不可用。
什么时候不必要写内核模块
内核编程是有趣的,但是写作(特别是调试)内核代码在真实的项目需要一定的技巧。一般来说,如果没有其他方法来解决你的问题,那么你可以下调内核级别。你就可以在用户空间:
你开发一个USB驱动程序—看看libusb。
你开发一个文件系统—试试FUSE。
你扩展Netfilter——libnetfilter_queue可以帮助你。
一般情况下,原生内核代码将有更好的表现,但对于许多项目这种性能损失并不重要。
由于内核编程一直是异步的,Linux没有主要()函数执行顺序运行模块。相反,您提供各种事件的回调,像这样的:
static int __init reverse_init(void)
{
printk(KERN_INFO "reverse device has been registeredn");
return 0;
}
static void __exit reverse_exit(void)
{
printk(KERN_INFO "reverse device has been unregisteredn");
}
module_init(reverse_init);
module_exit(reverse_exit);
在这里,我们定义函数被称为模块的插入和删除。只有第一个是必需的。现在,他们只是打印一条消息到内核环缓冲区(从用户空间访问通过dmesg命令);KERN_INFO是一个日志级别(注意没有逗号)。__init和__exit是属性—— 一块加载到函数或者变量的元数据。这种属性在用户空间的C代码中很少见,但在内核中很常见。所有标有__init在初始化后会被回收(记得“Freeing unused kernel memory ...”消息?)。__exit表示函数被安全的优化了,当代码被静态的编译进内核。最后,module_init()和module_exit()宏设置reverse_init()和reverse_exit()函数作为我们的生命周期回调模块。实际的函数名不重要,如果你愿意可以叫他们init()和exit()或start()和stop()。它们是声明为静态的,因此在外部模块看不见。事实上,任何函数在内核中是看不见的,除非显式导出。然而,前缀你的功能模块名称是一种常见的内核程序员之间的约定。
这些都是光秃秃的骨头—让我们做得更有趣。模块可以接受参数,如下:
# modprobe foo bar=1
modinfo命令显示所有参数接受模块,而这些也可以在/sys/module//parameters中当做文件使用。我们的模块需要一个缓冲区来存储参数——我们使它的大小可由用户配置。添加以下三行于MODULE_DESCRIPTION()之下:
static unsigned long buffer_size = 8192;
module_param(buffer_size, ulong, (S_IRUSR | S_IRGRP | S_IROTH));
MODULE_PARM_DESC(buffer_size, "Internal buffer size");
在这里,我们定义了一个变量来存储值,包装成一个参数,并通过sysfs使每个人可读。参数的描述(最后一行)出现在modinfo的输出。
用户可以直接设置buffer_size,我们需要在reverse_init()来清除无效取值。你应该经常检查来自内核外面的数据——如果你不(这样做),就是在将内核置身于异常或者安全漏洞中。
static int __init reverse_init()
{
if (!buffer_size)
return -1;
printk(KERN_INFO
"reverse device has been registered, buffer size is %lu bytesn",
buffer_size);
return 0;
}
从模块初始化函数返回非零值表示失败。
Linux内核是你开发模块时的一切来源。然而,它非常的大而且可能有接二连三的困难。幸运的是,这里有很多方法,使它更容易导航大型代码库。首先,有Cscope—一个古老的工具,运行在一个终端。简单地运行make cscope & &cscope在内核代码的顶级目录。Cscope与Vim和Emacs集成性很好,因此你可以使用它不用离开你最喜欢用的编辑器。
如果基于终端工具不是你的菜,访问http://lxr.free-electrons.com。它是一个基于web的内核的导航工具但不像Cscope尽可能多的功能(例如,你不能很容易找到函数的用法),但它仍然提供了足够的快速查找。
现在是时候编译模块。你需要内核版本的运行头文件(linux-headers或等效包)和build-essential(或类似包)。接下来,是时候创建样板Makefile:
obj-m += reverse.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来创建你的第一个模块. 如果之前的输入都正确, 你会在当前目录下找到一个reverse.ko文件。通过命令sudo insmod reverse.ko, 将它插入内核;运行:
$ dmesg | tail -1
[ 5905.042081] reverse device has been registered, buffer size is 8192 bytes
恭喜你!然而,现在这一行是在说谎——还没有设备节点。让我们解决它。
杂项设备
在Linux中,有一个特殊的字符设备类型称为“杂项”(或简单的“混杂”)。这是专为小型设备驱动程序与一个单一入口点,并且正是我们需要的。所有misc设备共享相同的主设备号(10),所以一个驱动程序(驱动程序/字符/ misc.c)可以照顾他们,和他们的小数字。在所有其他感官,他们只是正常字符设备。
注册一个次要版本号(和一个入口点)的设备,您声明结构体misc_device,填补它的字段(注意语法),并调用misc_register()这个结构的指针。为此,您还需要包括linux / miscdevice的h数据源文件:
static struct miscdevice reverse_misc_device = {
.minor = MISC_DYNAMIC_MINOR,
.name = "reverse",
.fops = &reverse_fops
};
static int __init reverse_init()
{
...
misc_register(&reverse_misc_device);
printk(KERN_INFO ...
}
这里,我们请求第一个可用(动态)小数量的设备命名为“反向”;th省略号表示省略了代码,我们已经看到了。别忘了注销设备模块的拆卸:
static void __exit reverse_exit(void)
{
misc_deregister(&reverse_misc_device);
...
}
The ‘fops’ field stores a pointer to a struct file_operations (declared in linux/fs.h), and this is the entry point for our module. reverse_fops is defined as:
static struct file_operations reverse_fops = {
.owner = THIS_MODULE,
.open = reverse_open,
...
.llseek = noop_llseek
};
再次,reverse_fops包含一组要执行的回调函数(也称为方法)当用户空间代码打开一个设备,读,写或关闭文件描述符。如果你忽略这些,一个明智的做法是用回退代替。这就是为什么我们显式地设置llseek方法noop_llseek(),它(顾名思义)没有。默认实现更改一个文件指针,我们不希望我们的设备现在seekable(这将是你今天的家庭作业)。
我打开关闭
让我们实现的方法。我们将为每个文件描述符分配一个新的缓冲区打开,关闭和自由。这不是真正的安全:如果一个用户空间应用程序漏洞描述符(也许故意),它可能占着内存,并使系统无法使用。你应该考虑这些可能性在现实世界中,但对于本教程,这是可以接受的。
我们需要一个结构来描述缓冲。内核提供了许多通用的数据结构:链表(双链)、哈希表、树等等。然而,缓冲区通常从头开始实现。我们会打电话给我们的“结构缓冲”:
struct buffer {
char *data, *end, *read_ptr;
unsigned long size;
};
数据是一个指向字符串的指针这个缓冲区存储,和结束后的第一个字节字符串结束。read_ptr就是read()应该开始阅读的数据。缓冲区大小存储完整性——现在,我们不使用这个字段。你不应该认为你的用户结构将正确地初始化所有这些,所以更好的封装缓冲区分配和重分配功能。他们通常叫buffer_alloc()和buffer_free()。
static struct buffer *buffer_alloc(unsigned long size)
{
struct buffer *buf;
buf = kzalloc(sizeof(*buf), GFP_KERNEL);
if (unlikely(!buf))
goto out;
...
out:
return buf;
}
内核内存分配与kmalloc()和释放与kfree();在kzalloc()味道all-zeroes设置内存。不像标准的malloc(),它的内核版本接收标志指定第二个参数的类型的内存要求。这里,GFP_KERNEL意味着我们需要一个正常的内核内存(而不是在直接存储器存取或高端内存区)和功能可以休眠(重新安排流程)。sizeof(* buf)是一种常见的办法通过指针的大小结构。
你应该经常检查kmalloc()的返回值:非关联化空指针将导致内核恐慌。还请注意可能的使用() macro。它(和相反的可能() macro)广泛用于内核表示条件几乎总是如此(或假)。它不影响控制流,但有助于现代处理器来提高性能,分支预测。
最后,注意转到。它们通常被认为是邪恶的,然而,Linux内核(和其他一些系统软件)雇佣了他们实现集中函数退出。这将导致更少的深度嵌套和更可读的代码,并就像try-ctach块中使用的高级语言。
与buffer_alloc()和buffer_free(),打开和关闭的实现方法变得非常简单。
static int reverse_open(struct inode *inode, struct file *file)
{
int err = 0;
file->private_data = buffer_alloc(buffer_size);
...
return err;
}
结构文件是一个标准的内核数据结构存储一个打开文件的信息,如当前文件位置(file->f_pos),国旗(file->f_flags)、或开放式(file->f_mode)。另一个领域,file->private_data用于将文件与一些任意数据。其类型是void *,外面是不透明的内核文件的所有者。我们存储缓冲区。
如果缓冲区分配失败,我们显示这个调用用户空间代码通过返回负值(-ENOMEM)。AC库做开放(2)系统调用(probably, glibc)将检测到并适当地设置errno。
学会读和写
“读”和“写”方法,真正的工作就完成了。当数据写入缓冲区,我们放弃之前的内容和反向就地一词,没有任何临时存储。阅读方法只是简单地将数据从内核缓冲区复制到用户空间。但应该reverse_read()方法做如果没有缓冲区中的数据吗?在用户空间,read()调用会阻塞,直到数据是可用的。在内核中,你必须等待。幸运的是,有一个机制,它被称为“等待队列”。
这个想法很简单。如果当前进程需要等待某些事件,其描述符(一个struct task_struct存储为“当前”)投入non-runnable(睡觉)状态并添加到队列中。然后安排()来选择另一个进程来运行。使用代码生成事件队列醒来服务员把他们带回TASK_RUNNING状态。调度器将选择其中一个在未来。Linux有几个non-runnable进程状态,尤其是TASK_INTERRUPTIBLE(睡眠,可以中断信号)和TASK_KILLABLE(睡眠过程可以被杀)。所有这一切应该正确处理和等待队列为你这样做。
自然的地方来储存我们读等待队列头结构缓冲,所以先添加wait_queue_head_t read_queue字段。你还应该包括linux / sched.h。等待队列可以静态地声明DECLARE_WAITQUEUE()macro。在我们的例子中,动态初始化是必要的,所以这一行添加到buffer_alloc():
init_waitqueue_head(&buf->read_queue);
我们等待可用的数据;或者read_ptr !=结束条件变为真。我们也想要等待可中断(说,通过Ctrl + C)。因此,“阅读”的方法应该是这样的:
static ssize_t reverse_read(struct file *file, char __user * out,
size_t size, loff_t * off)
{
struct buffer *buf = file->private_data;
ssize_t result;
while (buf->read_ptr == buf->end) {
if (file->f_flags & O_NONBLOCK) {
result = -EAGAIN;
goto out;
}
if (wait_event_interruptible
(buf->read_queue, buf->read_ptr != buf->end)) {
result = -ERESTARTSYS;
goto out;
}
}
...
我们一直循环直到数据和使用wait_event_interruptible()(这是一个宏,不是一个函数,这就是为什么队列是通过值)等如果不是。如果wait_event_interruptible(),中断,它返回一个非零值,我们翻译-ERESTARTSYS。是指系统调用应该重新启动。file->f_flags检查占在非阻塞模式下打开文件:如果没有数据,我们返回 -EAGAIN。
我们不能用如果()代替()然而(),因为可以有许多流程等数据。运用方法时,调度器选择以不可预知的方式运行,那么这段代码的时候有机会执行缓冲区又可以是空的。现在我们需要将数据从buf - >数据复制到用户空间。copy_to_user()核函数:
size = min(size, (size_t) (buf->end - buf->read_ptr));
if (copy_to_user(out, buf->read_ptr, size)) {
result = -EFAULT;
goto out;
}
用户空间指针调用可能会失败,如果是错误的,如果这种事情发生,我们返回-EFAULT。记住不要相信内核之外的东西!
buf->read_ptr += size;
result = size;
out:
return result;
}
简单的算术是必需的,以至于可以在任意块读取的数据。该方法返回的字节数读或错误代码。
写方法更简单和更短。首先,我们检查缓冲区有足够的空间,然后我们使用copy_from_userspace()函数获取数据。然后read_ptr和结束指针重置缓冲内容是相反的:
buf->end = buf->data + size;
buf->read_ptr = buf->data;
if (buf->end > buf->data)
reverse_phrase(buf->data, buf->end - 1);
这里,reverse_phrase()所有重担依赖于reverse_word()函数,它非常短,内联标记。这是另一种常见的优化;然而,你不应该过度使用它,因为内联使内核映像很小。
最后,我们需要运行read_queue流程等数据,如前所述。wake_up_interruptible():
wake_up_interruptible(&buf->read_queue);
唷!您现在拥有了一个内核模块,至少成功编译。现在是时候测试它了。
调试内核代码
也许最常见的内核调试方法是印刷。您可以使用普通printk()与KERN_DEBUG日志级别(大概)如果你的愿望。然而,有更好的方法。使用pr_debug()或dev_dbg(),如果您正在编写一个设备驱动程序,有自己的“结构设备”:他们支持动态调试(dyndbg)功能,可以启用或禁用请求(参见文档/ dynamic-debug-howto.txt)。纯粹的开发信息,使用pr_devel(),这成为一个空操作,除非定义调试。要启用调试模块,包括:
CFLAGS_reverse.o := -DDEBUG
Makefile。之后,使用dmesg命令来查看生成的调试消息pr_debug()或pr_devel()。或者,您可以直接发送调试消息到控制台。要做到这一点,要么console_loglevel内核变量设置为8或更高版本(回声8 > /proc/sys/kernel/printk)或临时打印调试信息在高问题像KERN_ERR日志级别。自然,您应该删除这类调试语句之前发布您的代码。
注意内核消息出现在控制台上,而不是在一个终端模拟器窗口如Xterm;这就是为什么你会发现建议不要在X内核开发环境。
惊喜,惊喜!
编译模块和加载到内核:
$ make
$ sudo insmod reverse.ko buffer_size=2048
$ lsmod
reverse 2419 0
$ ls -l /dev/reverse
crw-rw-rw- 1 root root 10, 58 Feb 22 15:53 /dev/reverse
一切似乎都在它应该在的地方。现在,测试模块是如何工作的,我们将编写一个小程序,改变它的第一个命令行参数。main()函数(无错误检查)看起来像这样:
int fd = open("/dev/reverse", O_RDWR);
write(fd, argv[1], strlen(argv[1]));
read(fd, argv[1], strlen(argv[1]));
printf("Read: %sn", argv[1]);
运行该程序:
$ ./test ‘A quick brown fox jumped over the lazy dog‘
READ:dog lazy the over jumped fox brown quick A
起作用了!学习这一点:尝试通过单字原图或单核苷酸短语,空的或非英语字符串(如果你有一个键盘布局设置)和任何其他东西。
现在,让我们把事情有点棘手。我们将创建两个进程共享的文件描述符(因此内核缓冲区)。一个不断将字符串写入装置,另一个会读它们。叉子(2)系统调用中使用下面的例子,但pthreads将工作。我也省略了的代码打开和关闭设备,错误检查(again):
char *phrase = "A quick brown fox jumped over the lazy dog";
if (fork())
/* Parent is the writer */
while (1)
write(fd, phrase, len);
else
/* child is the reader */
while (1) {
read(fd, buf, len);
printf("Read: %sn", buf);
}
你希望这个程序输出什么?下面是我的笔记本电脑里有什么
阅读:dog lazy the over jumped fox brown quick A
阅读:A kcicq brown fox jumped over the lazy dog
阅读:A kciuq nworb xor jumped fox brown quick A
阅读:A kciuq nworb xor jumped fox brown quick A
…
这儿怎么了?这是一个竞赛。我们认为读和写是原子,或者执行一个指令从一开始直到结束。然而内核是一个并发的野兽,它可以很容易地重新安排流程运行的内核部分写操作在某处reverse_phrase()函数。如果进程,read()将作者是有机会完成之前,它会看到数据处于不一致的状态。这样的错误是真的很难调试。但是如何修复它?
基本上,我们需要确保没有阅读方法可以执行,直到写方法返回。如果你设定一个多线程应用程序中,您可能已经看到同步原语(锁)互斥锁和信号量。Linux也他们,但是有细微差别。内核代码可以运行在进程上下文(用户空间代码的“代表”工作,作为我们的方法做),在中断上下文(例如,在一个IRQ处理程序)。如果你在进程上下文和锁已经被你需要,你只是睡觉和重试,直到你成功。你不能睡在中断上下文,因此代码在一个循环中旋转,直到锁可用。相应的原始称为自旋锁,但在我们的例子中,一个简单的互斥对象,只有一个进程可以在给定时间"持有"——就足够了。一个真实的代码也可以使用读写信号量,由于性能的原因。
也可以使用读写信号量,由于性能的原因。
锁始终保护一些数据(在我们的例子中,一个“结构缓冲”实例),而且它是很常见的将其纳入结构保护。所以我们添加一个互斥锁(struct互斥锁)到“结构缓冲”。我们还必须与mutex_init初始化互斥锁();buffer_alloc()是一个好地方。使用互斥锁的代码也必须包括linux / mutex.h。
互斥是更像一个红绿灯是没有用的,除非司机看,信号。所以我们需要更新reverse_read()和reverse_write()来获得互斥锁之前做任何缓冲和释放它当他们完成。让我们看看阅读方法——写的作品只是相同的方式:
static ssize_t reverse_read(struct file *file, char __user * out,
size_t size, loff_t * off)
{
struct buffer *buf = file->private_data;
ssize_t result;
if (mutex_lock_interruptible(&buf->lock)) {
result = -ERESTARTSYS;
goto out;
}
我们获得锁的函数在刚开始的时候。mutex_lock_interruptible()获取互斥锁并返回或把过程睡觉,直到互斥。和之前一样,_interruptible后缀意味着睡眠可以中断信号。
while (buf->read_ptr == buf->end) {
mutex_unlock(&buf->lock);
/* ... wait_event_interruptible() here ... */
if (mutex_lock_interruptible(&buf->lock)) {
result = -ERESTARTSYS;
goto out;
}
}
下面是我们的”等数据”循环。你不应该睡觉当持有互斥锁,或可能发生的情况称为“死锁”。所以,如果没有数据,我们释放互斥锁并调用wait_event_interruptible()。当它返回,我们重新获取互斥锁并继续像往常一样:
if (copy_to_user(out, buf->read_ptr, size)) {
result = -EFAULT;
goto out_unlock;
}
...
out_unlock:
mutex_unlock(&buf->lock);
out:
return result;
最后,函数结束时互斥对象已被解锁或者互斥锁被持有时发生错误。重新编译模块(别忘了重新加载)并再次运行第二个测试。现在您应该看到没有损坏的数据。
接下来是什么?
现在你是对内核黑客有点了解。我们刚刚触及只是表面的问题,其实还有更多问题。我们的第一个模块是故意简单的,然而你学到的概念将在更复杂的场景中也是保持不变的。并发性、方法表、注册回调,将流程睡眠和唤醒他们每个内核黑客应该做的事情,现在你已经看到他们在行动。也许你的内核代码最终会在主线Linux源代码树,如果发生这种情况写信给我们!