搜索
❏ 站外平台:

黑客内核:编写属于你的第一个Linux内核模块

译者: LCTT joeren

| 2014-06-24 09:00   评论: 15 收藏: 15 分享: 12    

导航

但你开发模块时,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),因此一个驱动(drivers/char/misc.c)就可以查看它们所有设备了,而这些设备用次设备号来区分。从其他意义来说,它们只是普通字符设备。

要为该设备注册一个次设备号(以及一个接入点),你需要声明struct 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 ...
}

这儿,我们为名为“reverse”的设备请求一个第一个可用的(动态的)次设备号;省略号表明我们之前已经见过的省略的代码。别忘了在模块卸下后注销掉该设备。

static void __exit reverse_exit(void)
{
    misc_deregister(&reverse_misc_device);
    ...
}

‘fops’字段存储了一个指针,指向一个file_operations结构(在Linux/fs.h中声明),而这正是我们模块的接入点。reverse_fops定义如下:

static struct file_operations reverse_fops = {
    .owner = THIS_MODULE,
    .open = reverse_open,
    ...
    .llseek = noop_llseek
};

另外,reverse_fops包含了一系列回调函数(也称之为方法),当用户空间代码打开一个设备,读写或者关闭文件描述符时,就会执行。如果你要忽略这些回调,可以指定一个明确的回调函数来替代。这就是为什么我们将llseek设置为noop_llseek(),(顾名思义)它什么都不干。这个默认实现改变了一个文件指针,而且我们现在并不需要我们的设备可以寻址(这是今天留给你们的家庭作业)。

关闭和打开

让我们来实现该方法。我们将给每个打开的文件描述符分配一个新的缓冲区,并在它关闭时释放。这实际上并不安全:如果一个用户空间应用程序泄漏了描述符(也许是故意的),它就会霸占RAM,并导致系统不可用。在现实世界中,你总得考虑到这些可能性。但在本教程中,这种方法不要紧。

我们需要一个结构函数来描述缓冲区。内核提供了许多常规的数据结构:链接列表(双联的),哈希表,树等等之类。不过,缓冲区常常从头设计。我们将调用我们的“struct buffer”:

struct buffer {
    char *data, *end, *read_ptr;
    unsigned long size;
};

data是该缓冲区存储的一个指向字符串的指针,而end指向字符串结尾后的第一个字节。read_ptrread()开始读取数据的地方。缓冲区的size是为了保证完整性而存储的 —— 目前,我们还没有使用该区域。你不能假设使用你结构体的用户会正确地初始化所有这些东西,所以最好在函数中封装缓冲区的分配和收回。它们通常命名为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()的风格是将内存设置为全零。不同于标准的malloc(),它的内核对应部分收到的标志指定了第二个参数中请求的内存类型。这里,GFP_KERNEL是说我们需要一个普通的内核内存(不是在DMA或高内存区中)以及如果需要的话函数可以睡眠(重新调度进程)。sizeof(*buf)是一种常见的方式,它用来获取可通过指针访问的结构体的大小。

你应该随时检查kmalloc()的返回值:访问NULL指针将导致内核异常。同时也需要注意unlikely()宏的使用。它(及其相对宏likely())被广泛用于内核中,用于表明条件几乎总是真的(或假的)。它不会影响到控制流程,但是能帮助现代处理器通过分支预测技术来提升性能。

最后,注意goto语句。它们常常为认为是邪恶的,但是,Linux内核(以及一些其它系统软件)采用它们来实施集中式的函数退出。这样的结果是减少嵌套深度,使代码更具可读性,而且非常像更高级语言中的try-catch区块。

有了buffer_alloc()buffer_free()openclose方法就变得很简单了。

static int reverse_open(struct inode *inode, struct file *file)
{
    int err = 0;
    file->private_data = buffer_alloc(buffer_size);
    ...
    return err;
}

struct file是一个标准的内核数据结构,用以存储打开的文件的信息,如当前文件位置(file->f_pos)、标志(file->f_flags),或者打开模式(file->f_mode)等。另外一个字段file->privatedata用于关联文件到一些专有数据,它的类型是void *,而且它在文件拥有者以外,对内核不透明。我们将一个缓冲区存储在那里。

如果缓冲区分配失败,我们通过返回否定值(-ENOMEM)来为调用的用户空间代码标明。一个C库中调用的open(2)系统调用(如 glibc)将会检测这个并适当地设置errno

学习如何读和写

“read”和“write”方法是真正完成工作的地方。当数据写入到缓冲区时,我们放弃之前的内容和反向地存储该字段,不需要任何临时存储。read方法仅仅是从内核缓冲区复制数据到用户空间。但是如果缓冲区还没有数据,revers_eread()会做什么呢?在用户空间中,read()调用会在有可用数据前阻塞它。在内核中,你就必须等待。幸运的是,有一项机制用于处理这种情况,就是‘wait queues’。

想法很简单。如果当前进程需要等待某个事件,它的描述符(struct task_struct存储‘current’信息)被放进非可运行(睡眠中)状态,并添加到一个队列中。然后schedule()就被调用来选择另一个进程运行。生成事件的代码通过使用队列将等待进程放回TASK_RUNNING状态来唤醒它们。调度程序将在以后在某个地方选择它们之一。Linux有多种非可运行状态,最值得注意的是TASK_INTERRUPTIBLE(一个可以通过信号中断的睡眠)和TASK_KILLABLE(一个可被杀死的睡眠中的进程)。所有这些都应该正确处理,并等待队列为你做这些事。

一个用以存储读取等待队列头的天然场所就是结构缓冲区,所以从为它添加wait_queue_headt read\queue字段开始。你也应该包含linux/sched.h头文件。可以使用DECLARE_WAITQUEUE()宏来静态声明一个等待队列。在我们的情况下,需要动态初始化,因此添加下面这行到buffer_alloc()

init_waitqueue_head(&buf->read_queue);

我们等待可用数据;或者等待read_ptr != end条件成立。我们也想要让等待操作可以被中断(如,通过Ctrl+C)。因此,“read”方法应该像这样开始:

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()被中断,它返回一个非0值,这个值代表-ERESTARTSYS。这段代码意味着系统调用应该重新启动。file->f_flags检查以非阻塞模式打开的文件数:如果没有数据,返回-EAGAIN

我们不能使用if()来替代while(),因为可能有许多进程正等待数据。当write方法唤醒它们时,调度程序以不可预知的方式选择一个来运行,因此,在这段代码有机会执行的时候,缓冲区可能再次空出。现在,我们需要将数据从buf->data 复制到用户空间。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);

耶!你现在已经有了一个内核模块,它至少已经编译成功了。现在,是时候来测试了。

LCTT 译者
joeren 💎
共计翻译: 160.0 篇 | 共计贡献: 1039
贡献时间:2014-05-15 -> 2017-03-19
访问我的 LCTT 主页 | 在 GitHub 上关注我


返回顶部

分享到微信

打开微信,点击顶部的“╋”,
使用“扫一扫”将网页分享至微信。