69天探索操作系统-第34天:Linux 内核模块开发

276 阅读9分钟

Pro5.avif

1.介绍

可加载内核模块(LKMs)是包含扩展操作系统运行内核代码的对象文件。它们提供了一种在不重新编译内核或重启系统的情况下向内核添加功能的方法。

关键概念

  • 动态加载: 模块可以在运行时加载和卸载,使内核具有高度灵活性。这允许系统资源在需要时仅加载必要功能,从而高效使用。例如,当连接特定硬件设备时,可以加载相应的模块,而在设备断开时卸载该模块。
  • 内核空间执行: 模块在内核空间运行,具有完整的系统权限,这意味着它们可以直接访问硬件和内核函数。这使得它们功能强大,但如果实现不当,也可能具有潜在的危险。一个编写不当的模块可能会使整个系统崩溃。
  • 模块化设计: Linux内核遵循模块化设计理念,将核心功能与可选功能分开,可选功能可以以模块的形式加载。这种设计使内核保持轻量级和高效,同时仍然具有可扩展性。

image.png

  • 加载模块: 当用户使用insmod加载模块时,内核会调用模块的初始化函数,该函数会注册模块的功能。
  • 卸载模块: 当用户使用rmmod卸载模块时,内核会调用模块的清理函数,该函数会注销模块的功能。

2. 内核模块架构

内核模块的架构围绕几个关键组件构建:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Description of your module");
MODULE_VERSION("1.0");

static int __init module_init_function(void) {
    printk(KERN_INFO "Module initialized\n");
    return 0;
}

static void __exit module_exit_function(void) {
    printk(KERN_INFO "Module removed\n");
}

module_init(module_init_function);
module_exit(module_exit_function);

核心组件

  • 模块入口点: 每个模块都必须有初始化和清理函数。module_init() 宏指定在模块加载时调用的函数,module_exit() 指定在模块卸载时调用的函数。
  • 模块信息: MODULE_LICENSEMODULE_AUTHORMODULE_DESCRIPTIONMODULE_VERSION 宏提供了有关模块的元数据。这些信息对于文档编写和调试非常有用。
  • 符号导出: 模块可以导出符号(函数或变量),供其他模块使用。这通过使用EXPORT_SYMBOL宏来实现。
  • 模块参数: 模块可以在加载时接受参数,从而实现运行时配置。这是通过使用module_param()宏来完成的。

3. 设置开发环境

要开发内核模块,您需要设置一个适当的发展环境。这包括安装必要的软件包和设置目录结构。

所需软件包和工具

sudo apt-get update
sudo apt-get install build-essential linux-headers-$(uname -r)

目录结构

module_project/
├── Makefile
├── module_main.c
└── module_utils.h

基本Makefile

obj-m += module_name.o
module_name-objs := module_main.o module_utils.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
  • Makefile: Makefile用于编译内核模块。obj-m变量指定要构建为内核模块的目标文件。make -C命令调用内核构建系统来编译模块。

  • 目录结构: module_main.c 文件包含模块的主要代码,而 module_utils.h 文件包含任何实用函数或定义。Makefile 用于将这些文件编译成一个内核模块。

4. 基本内核模块结构

让我们创建一个完整的、可运行的简单“Hello, Kernel!”模块示例。

// you can save it as hello_module.c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple Hello World module");
MODULE_VERSION("1.0");

static int __init hello_init(void) {
    printk(KERN_INFO "Hello, Kernel!\n");
    return 0;
}

static void __exit hello_exit(void) {
    printk(KERN_INFO "Goodbye, Kernel!\n");
}

module_init(hello_init);
module_exit(hello_exit);

解释

  • 初始化函数: 当模块被加载时,会调用hello_init()函数。它使用printk()向内核日志打印一条消息。
  • 清理函数: 当模块被卸载时,会调用hello_exit()函数。它也会向内核日志打印一条消息。
  • 模块元数据: MODULE_* 宏提供了有关模块的信息,例如其许可证作者、描述和版本。

运行代码

  1. 将代码保存到名为hello_module.c的文件中。
  2. 创建一个如前一部分所示的Makefile。
  3. 运行make来编译模块。
  4. 使用sudo insmod hello_module.ko加载模块。
  5. 使用dmesg检查内核日志,查看“Hello, Kernel!”消息。
  6. 使用sudo rmmod hello_module卸载模块。
  7. 再次检查内核日志,“Goodbye, Kernel!”消息。

5. 模块参数

模块参数允许在加载模块时传递配置选项。这对于在运行时配置模块的行为非常有用。

#include <linux/module.h>
#include <linux/moduleparam.h>

static int value = 42;
static char *name = "default";

module_param(value, int, 0644);
MODULE_PARM_DESC(value, "An integer value");
module_param(name, charp, 0644);
MODULE_PARM_DESC(name, "A character string");

解释

  • 模块参数: 模块参数宏(module_param())定义了一个可以在模块加载时传递给模块的参数。参数可以是各种类型,例如 int、charp(字符指针)等。
  • 权限: 0644 参数指定了 sysfs 中参数文件的权限。这允许您从用户空间读取和写入参数。
  • 参数描述: MODULE_P_DESC 宏提供了参数的描述,这对于文档编写很有用。

运行代码

  1. 将参数定义添加到模块代码中。
  2. 使用make编译模块。
  3. 加载带有参数的模块:sudo insmod hello_module.ko value=100 name="test"。
  4. 检查/sys/module/hello_module/parameters/中的参数值。

6. 字符设备驱动程序示例

字符设备驱动程序是一种内核模块,允许用户空间程序与硬件设备交互。以下是一个简单的字符设备驱动程序的完整示例。

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>

#define DEVICE_NAME "chardev"
#define CLASS_NAME "char_class"

static int major_number;
static struct class *char_class = NULL;
static struct cdev char_cdev;

static int device_open(struct inode *inode, struct file *file) {
    printk(KERN_INFO "Device opened\n");
    return 0;
}

static int device_release(struct inode *inode, struct file *file) {
    printk(KERN_INFO "Device closed\n");
    return 0;
}

static ssize_t device_read(struct file *file, char __user *buffer,
                          size_t length, loff_t *offset) {
    char message[] = "Hello from kernel\n";
    size_t message_len = strlen(message);

    if (*offset >= message_len)
        return 0;

    if (length > message_len - *offset)
        length = message_len - *offset;

    if (copy_to_user(buffer, message + *offset, length))
        return -EFAULT;

    *offset += length;
    return length;
}

static ssize_t device_write(struct file *file, const char __user *buffer,
                           size_t length, loff_t *offset) {
    printk(KERN_INFO "Received %zu bytes\n", length);
    return length;
}

static struct file_operations fops = {
    .open = device_open,
    .release = device_release,
    .read = device_read,
    .write = device_write,
};

static int __init chardev_init(void) {
    // allocate major number
    major_number = register_chrdev(0, DEVICE_NAME, &fops);
    if (major_number < 0) {
        printk(KERN_ALERT "Failed to register major number\n");
        return major_number;
    }

    // register device class
    char_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(char_class)) {
        unregister_chrdev(major_number, DEVICE_NAME);
        printk(KERN_ALERT "Failed to register device class\n");
        return PTR_ERR(char_class);
    }

    // register device driver
    if (IS_ERR(device_create(char_class, NULL, MKDEV(major_number, 0),
                            NULL, DEVICE_NAME))) {
        class_destroy(char_class);
        unregister_chrdev(major_number, DEVICE_NAME);
        printk(KERN_ALERT "Failed to create device\n");
        return PTR_ERR(char_class);
    }

    printk(KERN_INFO "Character device registered\n");
    return 0;
}

static void __exit chardev_exit(void) {
    device_destroy(char_class, MKDEV(major_number, 0));
    class_unregister(char_class);
    class_destroy(char_class);
    unregister_chrdev(major_number, DEVICE_NAME);
    printk(KERN_INFO "Character device unregistered\n");
}

module_init(chardev_init);
module_exit(chardev_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple character device driver");
MODULE_VERSION("1.0");

我们在上面的代码中做了什么?

  • 设备操作: 文件操作结构定义了处理打开、读取、写入和关闭设备等操作的函数。
  • 设备注册: register_chrdev() 函数将字符设备注册到内核中。 class_create()device_create() 函数在 /dev 中创建设备类和设备节点。
  • 设备清理: chardev_exit() 函数在模块卸载时注销设备并清理资源。

运行代码

  1. 将代码保存到名为chardev.c的文件中。
  2. 使用make编译模块。
  3. 使用sudo insmod chardev.ko加载模块。
  4. 使用dmesg检查内核日志,查看“字符设备已注册”消息。
  5. 使用用户空间程序或cat /dev/chardev等命令与设备交互。
  6. 使用sudo rmmod chardev卸模块。
  7. 再次检查内核日志,查看“字符设备已注销”消息。

7. 调试内核模块

调试内核模块可能具有挑战性,因为它们在内核空间中运行。不过,你可以使用几种技术来有效地调试它们。

printk 级别

printk(KERN_EMERG   "System is unusable\n");
printk(KERN_ALERT   "Action must be taken immediately\n");
printk(KERN_CRIT    "Critical conditions\n");
printk(KERN_ERR     "Error conditions\n");
printk(KERN_WARNING "Warning conditions\n");
printk(KERN_NOTICE  "Normal but significant\n");
printk(KERN_INFO    "Informational\n");
printk(KERN_DEBUG   "Debug-level messages\n");

使用 KGDB

KGDB 是一个内核调试器,允许您使用 GDB 调试内核。要使用 KGDB,您需要在内核配置中启用它,并使用适当的参数启动内核。

# enable KGDB in kernel config
CONFIG_KGDB=y
CONFIG_KGDB_SERIAL_CONSOLE=y

# boot parameters
kgdboc=ttyS0,115200 kgdbwait
  • printk 级别: printk() 函数允许你以不同的严重级别记录消息。这些消息可以使用 dmesg 查看。
  • KGDB: KGDB 允许你使用 GDB 调试内核。你可以设置断点、检查变量,并像调试用户空间程序一样逐步执行代码。

8. 最佳实践与安全

在开发内核模块时,遵循最佳实践对于确保可靠性和安全性非常重要。

  • 错误处理: 始终检查返回值并优雅地处理错误。一个崩溃的内核模块可能会使整个系统崩溃。
  • 资源管理: 在清理函数中释放所有已分配的资源。这包括内存、设备注册以及任何其他资源。
  • 并发: 使用适当的锁定机制来防止竞态条件。内核高度并发,您的模块必须是线程安全的。
  • 安全: 验证用户输入并检查权限。恶意用户可能会利用编写不当的模块获取对系统的未授权访问。
  • 文档: 保持清晰的文档和注释。内核模块可能很复杂,良好的文档对于维护和调试至关重要。

9. 总结

内核模块开发是Linux系统编程中复杂但强大的一个方面。本指南涵盖了创建、加载和管理内核模块的基础知识,包括字符设备驱动程序和调试技术。提供的示例和最佳实践应作为开发可靠内核模块的基础。

通过遵循本文中概述的步骤,您应该能够创建、编译、加载和调试自己的内核模块。请记住,始终要彻底测试您的模块,并遵循最佳实践,以确保系统的稳定性和安全性。