《Linux 设备驱动开发详解:基于最新的 Linux 4.0 内核》 第 4 章 Linux 内核模块

《Linux 设备驱动开发详解:基于最新的 Linux 4.0 内核》

第 4 章 Linux 内核模块

参考:宋宝华 著,机械工业出版社,2015年版


4.1 Linux 内核模块简介

4.1.1 什么是内核模块

Linux 内核模块(Loadable Kernel Module,LKM)是一种可以在运行时动态加载和卸载的内核代码单元。它允许在不重新编译和重启内核的情况下,向内核添加或移除功能。

宋宝华在书中对内核模块的定义:

"内核模块是 Linux 内核向外部提供的一个插接口,在内核中开了一个口子,允许外部代码插入内核运行。"

4.1.2 为什么需要内核模块

Linux 内核采用**宏内核(Monolithic Kernel)**架构,所有功能运行在同一地址空间。如果将所有驱动都编译进内核,会带来以下问题:

复制代码
问题一:内核体积庞大
  Linux 支持数千种硬件设备,若全部编译进内核:
  内核镜像可能超过 100MB,严重影响启动速度和内存占用

问题二:灵活性差
  添加新驱动或修改现有驱动必须重新编译整个内核(耗时数十分钟)
  然后重启系统才能生效

问题三:资源浪费
  大多数系统只使用少数几种硬件,其他驱动占用内存却从不使用

内核模块的优势

复制代码
优势一:按需加载
  只在需要时加载对应驱动,不使用时卸载,节省内存

优势二:动态更新
  修改驱动后只需重新编译该模块,rmmod + insmod 即可生效
  无需重新编译整个内核,无需重启系统

优势三:内核体积小
  基础内核只包含核心功能,其他功能以模块形式按需加载

优势四:开发调试方便
  驱动开发过程中频繁修改代码,模块方式可以快速迭代测试

4.1.3 内核模块与应用程序的对比

复制代码
┌─────────────────────────────────────────────────────────────┐
│              内核模块 vs 应用程序                             │
├──────────────────┬──────────────────────────────────────────┤
│      对比项       │    内核模块          │    应用程序         │
├──────────────────┼──────────────────────┼────────────────────┤
│ 运行空间          │ 内核空间(Ring 0)    │ 用户空间(Ring 3)  │
│ 入口函数          │ module_init()        │ main()             │
│ 退出函数          │ module_exit()        │ return / exit()    │
│ 库函数            │ 内核 API(printk等) │ glibc(printf等)  │
│ 运行方式          │ 被动响应(事件驱动)  │ 主动执行(顺序)    │
│ 崩溃影响          │ 整个系统崩溃         │ 仅当前进程退出      │
│ 浮点运算          │ 不能直接使用          │ 可以自由使用        │
│ 内存分配          │ kmalloc/vmalloc      │ malloc/new         │
│ 调试方式          │ printk/dmesg/kgdb    │ printf/gdb         │
└──────────────────┴──────────────────────┴────────────────────┘

4.2 Linux 内核模块程序结构

4.2.1 最简内核模块

一个最简单的内核模块只需要包含以下要素:

c 复制代码
/*
 * hello.c ------ 最简单的 Linux 内核模块
 */
#include <linux/init.h>     /* module_init / module_exit */
#include <linux/module.h>   /* MODULE_LICENSE 等宏 */
#include <linux/kernel.h>   /* printk / KERN_INFO */

/* 模块加载函数 */
static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, Linux Kernel Module!\n");
    return 0;
}

/* 模块卸载函数 */
static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, Linux Kernel Module!\n");
}

module_init(hello_init);    /* 注册加载函数 */
module_exit(hello_exit);    /* 注册卸载函数 */

MODULE_LICENSE("GPL v2");   /* 许可证声明(必须) */

4.2.2 内核模块的完整程序结构

一个完整的内核模块通常包含以下六个组成部分:

复制代码
内核模块程序结构:
┌─────────────────────────────────────────────────────────┐
│  1. 头文件包含(#include)                               │
│     必须包含 <linux/module.h>,其他按需包含              │
├─────────────────────────────────────────────────────────┤
│  2. 模块参数定义(module_param)                         │
│     可选,允许加载时传入参数                              │
├─────────────────────────────────────────────────────────┤
│  3. 模块加载函数(__init)                               │
│     insmod 时执行,完成初始化工作                         │
├─────────────────────────────────────────────────────────┤
│  4. 模块卸载函数(__exit)                               │
│     rmmod 时执行,完成清理工作                            │
├─────────────────────────────────────────────────────────┤
│  5. 导出符号(EXPORT_SYMBOL)                            │
│     可选,向其他模块暴露函数或变量                         │
├─────────────────────────────────────────────────────────┤
│  6. 模块声明与描述(MODULE_XXX 宏)                      │
│     LICENSE(必须)、AUTHOR、DESCRIPTION、VERSION        │
└─────────────────────────────────────────────────────────┘

完整模块示例

c 复制代码
/*
 * complete_module.c ------ 展示内核模块完整结构的示例
 */
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/slab.h>      /* kmalloc/kfree */
#include <linux/list.h>      /* 链表操作 */

/* ── 1. 模块参数 ── */
static int debug_level = 0;
static char *device_name = "mydevice";
module_param(debug_level, int, 0644);
module_param(device_name, charp, 0444);

/* ── 2. 模块内部数据结构 ── */
struct my_item {
    int id;
    char data[32];
    struct list_head list;
};

static LIST_HEAD(item_list);
static int item_count = 0;

/* ── 3. 内部函数 ── */
static int add_item(int id, const char *data)
{
    struct my_item *item = kzalloc(sizeof(*item), GFP_KERNEL);
    if (!item)
        return -ENOMEM;

    item->id = id;
    strncpy(item->data, data, sizeof(item->data) - 1);
    list_add_tail(&item->list, &item_list);
    item_count++;

    if (debug_level > 0)
        pr_info("%s: 添加条目 id=%d\n", device_name, id);

    return 0;
}

/* ── 4. 导出符号(供其他模块使用)── */
int get_item_count(void)
{
    return item_count;
}
EXPORT_SYMBOL(get_item_count);

/* ── 5. 模块加载函数 ── */
static int __init complete_module_init(void)
{
    int ret;

    pr_info("complete_module: 模块加载,debug_level=%d, device=%s\n",
            debug_level, device_name);

    ret = add_item(1, "first item");
    if (ret < 0) {
        pr_err("complete_module: 添加条目失败\n");
        return ret;
    }

    ret = add_item(2, "second item");
    if (ret < 0) {
        pr_err("complete_module: 添加条目失败\n");
        goto err_cleanup;
    }

    pr_info("complete_module: 初始化完成,共 %d 个条目\n", item_count);
    return 0;

err_cleanup:
    /* 清理已添加的条目 */
    {
        struct my_item *item, *tmp;
        list_for_each_entry_safe(item, tmp, &item_list, list) {
            list_del(&item->list);
            kfree(item);
        }
    }
    return ret;
}

/* ── 6. 模块卸载函数 ── */
static void __exit complete_module_exit(void)
{
    struct my_item *item, *tmp;

    list_for_each_entry_safe(item, tmp, &item_list, list) {
        list_del(&item->list);
        kfree(item);
    }

    pr_info("complete_module: 模块卸载,已清理所有条目\n");
}

module_init(complete_module_init);
module_exit(complete_module_exit);

/* ── 7. 模块声明 ── */
MODULE_LICENSE("GPL v2");
MODULE_AUTHOR("参考宋宝华《Linux设备驱动开发详解》");
MODULE_DESCRIPTION("展示内核模块完整结构的示例");
MODULE_VERSION("1.0");

4.3 模块加载函数

4.3.1 加载函数的定义

模块加载函数在执行 insmod 命令时被内核调用,用于完成模块的初始化工作:

c 复制代码
/*
 * 模块加载函数的标准形式
 *
 * static:限制函数作用域,避免与其他模块的同名函数冲突
 * int:返回值类型,0 表示成功,负值表示失败
 * __init:修饰符,告诉内核此函数只在初始化时使用
 */
static int __init my_module_init(void)
{
    /* 初始化代码 */
    return 0;   /* 成功返回 0 */
}

module_init(my_module_init);

4.3.2 __init 修饰符的作用

__init 是一个重要的内存优化机制:

c 复制代码
/*
 * __init 的定义(arch/arm/include/asm/init.h):
 * #define __init  __section(.init.text) __cold notrace
 *
 * 作用:将被修饰的函数放入 .init.text 段
 * 内核初始化完成后,.init.text 段占用的内存会被释放
 * 这样可以节省几百 KB 的内核内存
 */

/* 内核内存段示意 */
/*
 * .text      ← 常驻内存的代码(驱动的 read/write/ioctl 等)
 * .init.text ← __init 函数(初始化完成后释放)
 * .exit.text ← __exit 函数(编译进内核时不需要)
 * .data      ← 全局变量
 * .init.data ← __initdata 修饰的初始化数据(初始化后释放)
 */

/* __initdata:修饰只在初始化时使用的数据 */
static int __initdata init_value = 100;

static int __init my_init(void)
{
    pr_info("初始化值:%d\n", init_value);  /* 使用 __initdata */
    return 0;
}

4.3.3 加载函数的返回值

加载函数的返回值决定模块是否加载成功:

c 复制代码
static int __init my_module_init(void)
{
    int ret;
    void __iomem *base;

    /* 返回 0:加载成功 */
    /* 返回负值:加载失败,内核会打印错误信息 */

    /* 常见错误码 */
    base = ioremap(0x44E09000, 0x1000);
    if (!base) {
        pr_err("ioremap 失败\n");
        return -ENOMEM;    /* 内存不足 */
    }

    ret = register_chrdev(240, "mydev", &my_fops);
    if (ret < 0) {
        pr_err("注册字符设备失败:%d\n", ret);
        iounmap(base);
        return -EBUSY;     /* 设备忙(设备号已被占用) */
    }

    /* 其他常见错误码:
     * -EINVAL   ← 无效参数
     * -ENODEV   ← 设备不存在
     * -ENOMEM   ← 内存不足
     * -EBUSY    ← 资源忙
     * -ENOENT   ← 文件/设备不存在
     * -EPERM    ← 权限不足
     * -ETIMEDOUT← 超时
     */

    return 0;
}

4.3.4 加载函数的错误处理模式

内核模块加载函数中推荐使用 goto 错误处理模式,确保资源按正确顺序释放:

c 复制代码
static int __init my_module_init(void)
{
    int ret;

    /* 步骤1:申请设备号 */
    ret = alloc_chrdev_region(&devno, 0, 1, "mydev");
    if (ret < 0) {
        pr_err("申请设备号失败\n");
        return ret;           /* 直接返回,无需清理 */
    }

    /* 步骤2:分配设备结构体 */
    my_dev = kzalloc(sizeof(*my_dev), GFP_KERNEL);
    if (!my_dev) {
        ret = -ENOMEM;
        goto err_alloc;       /* 跳转到清理步骤2 */
    }

    /* 步骤3:ioremap */
    my_dev->base = ioremap(PHYS_BASE, SIZE);
    if (!my_dev->base) {
        ret = -ENOMEM;
        goto err_ioremap;     /* 跳转到清理步骤3 */
    }

    /* 步骤4:申请中断 */
    ret = request_irq(irq, my_irq_handler, 0, "mydev", my_dev);
    if (ret) {
        goto err_irq;         /* 跳转到清理步骤4 */
    }

    /* 步骤5:注册字符设备 */
    cdev_init(&my_dev->cdev, &my_fops);
    ret = cdev_add(&my_dev->cdev, devno, 1);
    if (ret) {
        goto err_cdev;        /* 跳转到清理步骤5 */
    }

    pr_info("mydev: 初始化成功\n");
    return 0;

    /* 错误处理:按申请的逆序释放资源 */
err_cdev:
    free_irq(irq, my_dev);
err_irq:
    iounmap(my_dev->base);
err_ioremap:
    kfree(my_dev);
err_alloc:
    unregister_chrdev_region(devno, 1);
    return ret;
}

关键原则 :资源释放的顺序必须与申请顺序相反,就像栈的 LIFO(后进先出)原则。


4.4 模块卸载函数

4.4.1 卸载函数的定义

模块卸载函数在执行 rmmod 命令时被内核调用,负责释放模块占用的所有资源:

c 复制代码
/*
 * 模块卸载函数的标准形式
 *
 * static:限制作用域
 * void:无返回值(卸载函数不能失败)
 * __exit:修饰符,告诉内核此函数只在卸载时使用
 */
static void __exit my_module_exit(void)
{
    /* 清理代码,释放所有资源 */
    /* 顺序与 init 函数中的申请顺序相反 */
}

module_exit(my_module_exit);

4.4.2 __exit 修饰符的作用

c 复制代码
/*
 * __exit 的作用:
 * 1. 将函数放入 .exit.text 段
 * 2. 如果模块被编译进内核(非模块方式,CONFIG_XXX=y),
 *    则 __exit 函数不会被编译(因为内建模块无法卸载)
 * 3. 节省内核内存
 *
 * 注意:如果模块没有 module_exit,则该模块不能被卸载
 *       (rmmod 会报错:ERROR: Module xxx is builtin)
 */

/* __exitdata:修饰只在卸载时使用的数据 */
static const char __exitdata exit_msg[] = "模块已卸载";

static void __exit my_exit(void)
{
    pr_info("%s\n", exit_msg);
}

4.4.3 卸载函数与加载函数的对应关系

卸载函数必须释放加载函数中申请的所有资源,且顺序相反:

c 复制代码
/* 加载函数申请资源的顺序(正序):*/
static int __init my_init(void)
{
    /* 1 */ alloc_chrdev_region(...)    → /* 对应卸载:unregister_chrdev_region */
    /* 2 */ kzalloc(...)               → /* 对应卸载:kfree */
    /* 3 */ ioremap(...)               → /* 对应卸载:iounmap */
    /* 4 */ request_irq(...)           → /* 对应卸载:free_irq */
    /* 5 */ cdev_add(...)              → /* 对应卸载:cdev_del */
    /* 6 */ device_create(...)         → /* 对应卸载:device_destroy */
    /* 7 */ class_create(...)          → /* 对应卸载:class_destroy */
    return 0;
}

/* 卸载函数释放资源的顺序(逆序):*/
static void __exit my_exit(void)
{
    /* 7 */ device_destroy(...)
    /* 6 */ class_destroy(...)
    /* 5 */ cdev_del(...)
    /* 4 */ free_irq(...)
    /* 3 */ iounmap(...)
    /* 2 */ kfree(...)
    /* 1 */ unregister_chrdev_region(...)
}

4.4.4 使用 devm_ 系列函数简化资源管理

Linux 4.0 推荐使用 devm_(device-managed)系列函数,驱动卸载时自动释放资源:

c 复制代码
static int my_probe(struct platform_device *pdev)
{
    struct my_dev *dev;
    int ret;

    /* devm_kzalloc:设备卸载时自动 kfree */
    dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL);
    if (!dev)
        return -ENOMEM;

    /* devm_ioremap_resource:设备卸载时自动 iounmap */
    dev->base = devm_ioremap_resource(&pdev->dev, res);
    if (IS_ERR(dev->base))
        return PTR_ERR(dev->base);

    /* devm_request_irq:设备卸载时自动 free_irq */
    ret = devm_request_irq(&pdev->dev, irq, my_irq_handler,
                           0, "mydev", dev);
    if (ret)
        return ret;

    /* 使用 devm_ 系列函数后,remove 函数可以大幅简化 */
    return 0;
}

/* 使用 devm_ 后,remove 函数几乎为空 */
static int my_remove(struct platform_device *pdev)
{
    /* devm_ 申请的资源会自动释放,无需手动清理 */
    pr_info("设备已移除\n");
    return 0;
}

4.5 模块参数

4.5.1 模块参数的定义

模块参数允许在加载模块时从命令行传入配置值,增加模块的灵活性:

c 复制代码
#include <linux/moduleparam.h>

/*
 * module_param(name, type, perm)
 *
 * name:参数名(同时也是模块内的变量名)
 * type:参数类型
 * perm:sysfs 中对应文件的权限(0 表示不在 sysfs 中创建文件)
 *
 * 支持的类型:
 *   bool     ← 布尔值(0/1 或 y/n)
 *   invbool  ← 反转布尔值
 *   int      ← 有符号整数
 *   uint     ← 无符号整数
 *   long     ← 长整数
 *   ulong    ← 无符号长整数
 *   short    ← 短整数
 *   ushort   ← 无符号短整数
 *   charp    ← 字符串指针(char *)
 *   byte     ← 字节(unsigned char)
 */

/* 定义各种类型的模块参数 */
static bool   enable    = true;
static int    timeout   = 1000;
static uint   buf_size  = 4096;
static char  *dev_name  = "mydevice";

module_param(enable,   bool,  0644);
module_param(timeout,  int,   0644);
module_param(buf_size, uint,  0444);   /* 只读 */
module_param(dev_name, charp, 0644);

/* 为参数添加描述(显示在 modinfo 输出中) */
MODULE_PARM_DESC(enable,   "使能设备(默认:true)");
MODULE_PARM_DESC(timeout,  "超时时间,单位毫秒(默认:1000)");
MODULE_PARM_DESC(buf_size, "缓冲区大小,单位字节(默认:4096)");
MODULE_PARM_DESC(dev_name, "设备名称(默认:mydevice)");

4.5.2 数组类型参数

c 复制代码
/*
 * module_param_array(name, type, nump, perm)
 *
 * name:数组变量名
 * type:数组元素类型
 * nump:指向实际传入元素个数的指针(可为 NULL)
 * perm:sysfs 权限
 */

static int  ports[4]    = {8080, 8081, 8082, 8083};
static int  port_count  = 0;   /* 实际传入的端口数量 */

module_param_array(ports, int, &port_count, 0644);
MODULE_PARM_DESC(ports, "监听端口列表(最多4个,默认:8080,8081,8082,8083)");

static int __init my_init(void)
{
    int i;
    pr_info("实际传入端口数:%d\n", port_count);
    for (i = 0; i < port_count; i++)
        pr_info("端口[%d] = %d\n", i, ports[i]);
    return 0;
}

4.5.3 模块参数的使用方式

bash 复制代码
# 加载时传入参数
sudo insmod my_module.ko enable=1 timeout=500 dev_name="uart0"

# 传入数组参数
sudo insmod my_module.ko ports=9090,9091,9092

# 查看模块参数(通过 sysfs)
ls /sys/module/my_module/parameters/
# enable  timeout  buf_size  dev_name

cat /sys/module/my_module/parameters/timeout
# 500

# 运行时修改参数(需要 perm 包含写权限,如 0644)
echo 2000 | sudo tee /sys/module/my_module/parameters/timeout

# 查看模块参数描述
modinfo my_module.ko
# parm:  enable:使能设备(默认:true) (bool)
# parm:  timeout:超时时间,单位毫秒(默认:1000) (int)
# parm:  buf_size:缓冲区大小,单位字节(默认:4096) (uint)
# parm:  dev_name:设备名称(默认:mydevice) (charp)

4.5.4 参数权限(perm)的含义

c 复制代码
/*
 * perm 参数使用标准 Linux 文件权限位:
 *
 * 0:不在 /sys/module/<name>/parameters/ 下创建文件
 *    参数只能在加载时设置,运行时无法查看或修改
 *
 * 0444(S_IRUGO):所有用户可读,不可写
 *    适合只读参数(如版本号、硬件地址)
 *
 * 0644(S_IRUGO | S_IWUSR):所有用户可读,root 可写
 *    适合可运行时调整的参数(如调试级别、超时时间)
 *
 * 0600(S_IRUSR | S_IWUSR):只有 root 可读写
 *    适合敏感参数
 *
 * 注意:内核文档建议不要使用 0222(只写)权限
 */

static int debug = 0;
module_param(debug, int, 0644);   /* root 可读写,其他用户只读 */

static const char *version = "1.0.0";
module_param(version, charp, 0444);  /* 所有用户只读 */

static int secret_key = 0;
module_param(secret_key, int, 0600); /* 只有 root 可读写 */

static int internal = 0;
module_param(internal, int, 0);      /* 不在 sysfs 中暴露 */

4.6 导出符号

4.6.1 符号导出的概念

Linux 内核模块之间可以相互调用函数和访问变量,但前提是被调用方必须显式导出这些符号:

复制代码
模块间符号共享机制:

模块 A(提供方)                    模块 B(使用方)
┌─────────────────────┐            ┌─────────────────────┐
│ int add(int a, int b)│            │ extern int add(...); │
│ {                   │            │                     │
│     return a + b;   │            │ static int __init   │
│ }                   │            │ b_init(void) {      │
│ EXPORT_SYMBOL(add); │──导出────→ │     int r = add(3,5)│
│                     │            │ }                   │
└─────────────────────┘            └─────────────────────┘

加载顺序:必须先加载模块 A,再加载模块 B
卸载顺序:必须先卸载模块 B,再卸载模块 A

4.6.2 EXPORT_SYMBOL 与 EXPORT_SYMBOL_GPL

Linux 提供两种符号导出宏,区别在于对模块许可证的要求:

c 复制代码
/*
 * EXPORT_SYMBOL(sym)
 * 导出符号,任何模块(包括闭源模块)都可以使用
 */
int my_add(int a, int b)
{
    return a + b;
}
EXPORT_SYMBOL(my_add);

/*
 * EXPORT_SYMBOL_GPL(sym)
 * 导出符号,只有声明了 GPL 兼容许可证的模块才能使用
 * 如果非 GPL 模块尝试使用,加载时会报错:
 * "my_func" [my_module.ko] undefined!
 */
int my_sensitive_func(void)
{
    /* 访问内核内部数据结构 */
    return 0;
}
EXPORT_SYMBOL_GPL(my_sensitive_func);

/*
 * EXPORT_SYMBOL_GPL_FUTURE(sym)
 * 当前行为与 EXPORT_SYMBOL 相同,但将来可能改为 GPL 专用
 * 用于过渡期
 */
EXPORT_SYMBOL_GPL_FUTURE(my_future_func);

4.6.3 符号导出的完整案例

模块 A(math_ops.c)------ 提供数学运算函数

c 复制代码
/*
 * math_ops.c ------ 提供数学运算,导出符号供其他模块使用
 */
#include <linux/module.h>
#include <linux/kernel.h>

/* 导出给所有模块使用的函数 */
int math_add(int a, int b)
{
    return a + b;
}
EXPORT_SYMBOL(math_add);

int math_sub(int a, int b)
{
    return a - b;
}
EXPORT_SYMBOL(math_sub);

/* 只导出给 GPL 模块使用的函数 */
int math_div(int a, int b)
{
    if (b == 0) {
        pr_err("math_ops: 除数不能为零\n");
        return -EINVAL;
    }
    return a / b;
}
EXPORT_SYMBOL_GPL(math_div);

static int __init math_ops_init(void)
{
    pr_info("math_ops: 模块加载,符号已导出\n");
    return 0;
}

static void __exit math_ops_exit(void)
{
    pr_info("math_ops: 模块卸载\n");
}

module_init(math_ops_init);
module_exit(math_ops_exit);
MODULE_LICENSE("GPL v2");
MODULE_DESCRIPTION("数学运算模块,演示符号导出");

模块 B(calc.c)------ 使用模块 A 导出的符号

c 复制代码
/*
 * calc.c ------ 使用 math_ops 模块导出的符号
 */
#include <linux/module.h>
#include <linux/kernel.h>

/* 声明外部符号(来自 math_ops 模块) */
extern int math_add(int a, int b);
extern int math_sub(int a, int b);
extern int math_div(int a, int b);

static int __init calc_init(void)
{
    pr_info("calc: 3 + 5 = %d\n", math_add(3, 5));
    pr_info("calc: 10 - 4 = %d\n", math_sub(10, 4));
    pr_info("calc: 15 / 3 = %d\n", math_div(15, 3));
    return 0;
}

static void __exit calc_exit(void)
{
    pr_info("calc: 模块卸载\n");
}

module_init(calc_init);
module_exit(calc_exit);
MODULE_LICENSE("GPL v2");   /* 必须是 GPL,才能使用 EXPORT_SYMBOL_GPL 导出的符号 */

加载与卸载

bash 复制代码
# 必须先加载被依赖的模块
sudo insmod math_ops.ko
sudo insmod calc.ko

# 查看符号依赖关系
lsmod | grep -E "math_ops|calc"
# calc       16384  0
# math_ops   16384  1 calc    ← math_ops 被 calc 引用(引用计数=1)

# 查看内核导出符号表
cat /proc/kallsyms | grep math_add
# ffffffffc0001000 T math_add [math_ops]

# 卸载顺序:先卸载依赖方
sudo rmmod calc
sudo rmmod math_ops

4.6.4 查看模块的符号依赖

bash 复制代码
# 查看模块导出的符号
cat Module.symvers
# 0x12345678  math_add  math_ops  EXPORT_SYMBOL
# 0x87654321  math_div  math_ops  EXPORT_SYMBOL_GPL

# 查看模块依赖的外部符号
nm calc.ko | grep " U "   # U 表示未定义(需要从外部导入)
# U math_add
# U math_div
# U math_sub

# modprobe 自动处理依赖(需要先 depmod)
sudo depmod -a
sudo modprobe calc   # 自动先加载 math_ops,再加载 calc

4.7 模块的声明与描述

4.7.1 MODULE_XXX 宏详解

Linux 提供了一系列 MODULE_XXX 宏用于描述模块的元信息,这些信息可以通过 modinfo 命令查看:

c 复制代码
/*
 * MODULE_LICENSE ------ 许可证声明(必须提供)
 *
 * 合法的许可证字符串:
 */
MODULE_LICENSE("GPL");              /* GNU General Public License v2 或更高 */
MODULE_LICENSE("GPL v2");           /* GNU General Public License v2 */
MODULE_LICENSE("GPL and additional rights");
MODULE_LICENSE("Dual BSD/GPL");     /* BSD 或 GPL 双重许可 */
MODULE_LICENSE("Dual MIT/GPL");     /* MIT 或 GPL 双重许可 */
MODULE_LICENSE("Dual MPL/GPL");     /* MPL 或 GPL 双重许可 */
MODULE_LICENSE("Proprietary");      /* 专有许可(会触发内核污染警告) */

/*
 * MODULE_AUTHOR ------ 作者信息
 * 可以多次使用,添加多个作者
 */
MODULE_AUTHOR("Zhang San <zhangsan@example.com>");
MODULE_AUTHOR("Li Si <lisi@example.com>");

/*
 * MODULE_DESCRIPTION ------ 模块功能描述
 */
MODULE_DESCRIPTION("A simple character device driver for XYZ hardware");

/*
 * MODULE_VERSION ------ 模块版本号
 * 建议使用语义化版本(Semantic Versioning)
 */
MODULE_VERSION("2.1.3");

/*
 * MODULE_ALIAS ------ 模块别名
 * 用于 modprobe 通过别名加载模块
 */
MODULE_ALIAS("char-major-240-0");   /* 字符设备别名 */
MODULE_ALIAS("platform:my-device"); /* 平台设备别名 */

/*
 * MODULE_DEVICE_TABLE ------ 设备 ID 表
 * 用于热插拔时自动加载驱动
 */
static const struct usb_device_id my_usb_ids[] = {
    { USB_DEVICE(0x1234, 0x5678) },
    {}
};
MODULE_DEVICE_TABLE(usb, my_usb_ids);

static const struct of_device_id my_of_ids[] = {
    { .compatible = "vendor,my-device" },
    {}
};
MODULE_DEVICE_TABLE(of, my_of_ids);

/*
 * MODULE_FIRMWARE ------ 声明模块需要的固件文件
 */
MODULE_FIRMWARE("my_device.bin");
MODULE_FIRMWARE("my_device_v2.bin");

4.7.2 modinfo 命令查看模块信息

bash 复制代码
modinfo my_driver.ko
# filename:       /path/to/my_driver.ko
# version:        2.1.3
# description:    A simple character device driver for XYZ hardware
# author:         Zhang San <zhangsan@example.com>
# author:         Li Si <lisi@example.com>
# license:        GPL v2
# alias:          platform:my-device
# alias:          char-major-240-0
# firmware:       my_device.bin
# srcversion:     A1B2C3D4E5F6A7B8C9D0E1F
# depends:        math_ops
# vermagic:       4.0.0 SMP mod_unload ARMv7
# parm:           debug:调试级别 (int)
# parm:           timeout:超时时间ms (int)

# vermagic 字段非常重要:
# 模块的 vermagic 必须与当前运行内核完全匹配,否则无法加载
# 4.0.0     ← 内核版本
# SMP       ← 支持对称多处理器
# mod_unload← 支持模块卸载
# ARMv7     ← CPU 架构

4.7.3 内核版本兼容性

c 复制代码
/*
 * 模块中可以使用条件编译处理不同内核版本的 API 差异
 */
#include <linux/version.h>

#if LINUX_VERSION_CODE >= KERNEL_VERSION(4, 0, 0)
    /* Linux 4.0+ 的新 API */
    timer_setup(&my_timer, my_timer_callback, 0);
#else
    /* Linux 4.0 之前的旧 API */
    init_timer(&my_timer);
    my_timer.function = my_timer_callback;
    my_timer.data = (unsigned long)my_dev;
#endif

/* LINUX_VERSION_CODE 是一个整数,格式为:
 * (主版本号 << 16) | (次版本号 << 8) | 修订号
 * 例如 Linux 4.0.0 = (4 << 16) | (0 << 8) | 0 = 0x040000
 */
pr_info("当前内核版本代码:0x%06X\n", LINUX_VERSION_CODE);
pr_info("UTS_RELEASE:%s\n", UTS_RELEASE);  /* 如 "4.0.0" */

4.8 模块的使用计数

4.8.1 使用计数的概念

模块使用计数(Reference Count)记录了当前有多少个用户正在使用该模块。当使用计数不为零时,模块不能被卸载

复制代码
使用计数的作用:

场景:驱动模块 A 被应用程序打开(open),此时执行 rmmod A
      如果没有使用计数保护,模块被卸载后,应用程序继续调用
      驱动函数会访问已释放的内存,导致内核崩溃

使用计数机制:
  应用程序 open() → 使用计数 +1(1)
  应用程序 close() → 使用计数 -1(0)
  rmmod 时检查使用计数:
    = 0 → 允许卸载
    > 0 → 拒绝卸载,返回 "ERROR: Module is in use"

4.8.2 Linux 2.6+ 的自动引用计数

在 Linux 2.6 之后,内核通过 try_module_get()module_put() 自动管理模块引用计数,驱动开发者通常不需要手动管理

c 复制代码
/*
 * 内核在以下情况自动增加模块引用计数:
 * 1. 应用程序 open() 设备文件时
 * 2. 另一个模块使用本模块导出的符号时
 *
 * 内核在以下情况自动减少模块引用计数:
 * 1. 应用程序 close() 设备文件时
 * 2. 依赖本模块的其他模块被卸载时
 */

/* file_operations 中设置 .owner = THIS_MODULE 即可启用自动引用计数 */
static const struct file_operations my_fops = {
    .owner = THIS_MODULE,   /* ← 关键:设置模块所有者 */
    .open  = my_open,
    .read  = my_read,
    .write = my_write,
    .release = my_release,
};

/*
 * 当 .owner = THIS_MODULE 时:
 * - 内核在调用 open() 前自动调用 try_module_get(THIS_MODULE)
 * - 内核在调用 release() 后自动调用 module_put(THIS_MODULE)
 * 驱动开发者无需手动管理引用计数
 */

4.8.3 手动管理引用计数

在某些特殊场景下,需要手动管理引用计数:

c 复制代码
#include <linux/module.h>

/*
 * try_module_get(module):尝试增加模块引用计数
 * 返回:非0(成功),0(模块正在被卸载,失败)
 *
 * module_put(module):减少模块引用计数
 */

/* 场景:模块 B 动态查找并使用模块 A 的功能 */
static int use_other_module(void)
{
    struct module *mod;

    /* 查找模块 A */
    mod = find_module("module_a");
    if (!mod)
        return -ENOENT;

    /* 增加引用计数,防止模块 A 在使用期间被卸载 */
    if (!try_module_get(mod)) {
        pr_err("模块 A 正在被卸载\n");
        return -EBUSY;
    }

    /* 使用模块 A 的功能 */
    /* ... */

    /* 使用完毕,减少引用计数 */
    module_put(mod);
    return 0;
}

/* 查看模块引用计数 */
// lsmod 输出的第三列就是引用计数
// Module    Size    Used by
// my_driver 16384   2        ← 被2个用户使用,不能卸载
// math_ops  16384   1 calc   ← 被 calc 模块引用

4.8.4 查看和调试引用计数

bash 复制代码
# 查看模块引用计数
lsmod
# Module          Size  Used by
# my_driver      16384  2          ← 引用计数为2,不能卸载
# math_ops       16384  1 calc     ← 被 calc 引用

# 尝试卸载被使用的模块
sudo rmmod my_driver
# rmmod: ERROR: Module my_driver is in use

# 查看谁在使用该模块
cat /proc/modules | grep my_driver
# my_driver 16384 2 - Live 0xbf000000

# 查看模块的详细引用信息
cat /sys/module/my_driver/refcnt
# 2

4.9 模块的编译

4.9.1 单文件模块的 Makefile

makefile 复制代码
# ============================================================
# 单文件模块 Makefile
# ============================================================

# 内核源码路径
KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build
PWD        := $(shell pwd)

# 要编译的模块(文件名.o → 文件名.ko)
obj-m := hello.o

all:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean

.PHONY: all clean

4.9.2 多文件模块的 Makefile

当一个模块由多个源文件组成时:

makefile 复制代码
# ============================================================
# 多文件模块 Makefile
# ============================================================

KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build
PWD        := $(shell pwd)

# 模块名
obj-m := my_driver.o

# 指定组成模块的源文件(去掉 .o 后缀)
# my_driver.ko 由 main.o + irq.o + dma.o 链接而成
my_driver-objs := main.o irq.o dma.o

all:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean

4.9.3 多模块的 Makefile

同时编译多个模块:

makefile 复制代码
# ============================================================
# 多模块 Makefile
# ============================================================

KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build
PWD        := $(shell pwd)

# 同时编译多个模块
obj-m := math_ops.o calc.o my_driver.o

# my_driver 由多个文件组成
my_driver-objs := main.o irq.o helper.o

all:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean

# 交叉编译目标
arm:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) \
		ARCH=arm \
		CROSS_COMPILE=arm-linux-gnueabihf- \
		modules

# 安装模块
install:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules_install
	depmod -a

.PHONY: all clean arm install

4.9.4 将模块编译进内核源码树

如果需要将自己的驱动集成到内核源码树中:

步骤一:将源文件放入合适的目录

bash 复制代码
# 将驱动文件复制到内核源码树
cp my_driver.c linux-4.0/drivers/char/

# 或创建新的子目录
mkdir linux-4.0/drivers/char/my_driver/
cp my_driver.c linux-4.0/drivers/char/my_driver/

步骤二:修改目录下的 Kconfig

kconfig 复制代码
# linux-4.0/drivers/char/Kconfig 中添加:

config MY_DRIVER
    tristate "My Character Device Driver"
    depends on SERIAL_CORE
    default m
    help
      This is my custom character device driver.
      To compile as a module, choose M.

步骤三:修改目录下的 Makefile

makefile 复制代码
# linux-4.0/drivers/char/Makefile 中添加:
obj-$(CONFIG_MY_DRIVER) += my_driver.o

# 如果是子目录:
obj-$(CONFIG_MY_DRIVER) += my_driver/

步骤四:配置并编译

bash 复制代码
make ARCH=arm menuconfig
# 找到 "My Character Device Driver" 并选择 [M] 或 [*]

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j$(nproc)

4.9.5 编译过程中的常见问题

bash 复制代码
# 问题一:vermagic 不匹配
sudo insmod my_driver.ko
# insmod: ERROR: could not insert module my_driver.ko: Invalid module format
# 原因:模块编译时使用的内核头文件版本与当前运行内核版本不一致
# 解决:使用与运行内核完全匹配的内核头文件重新编译

# 问题二:未定义符号
sudo insmod calc.ko
# insmod: ERROR: could not insert module calc.ko: Unknown symbol in module
# 原因:calc.ko 依赖的 math_ops 模块未加载
# 解决:先加载 math_ops.ko

# 问题三:模块已加载
sudo insmod my_driver.ko
# insmod: ERROR: could not insert module my_driver.ko: File exists
# 解决:先卸载再加载
sudo rmmod my_driver && sudo insmod my_driver.ko

# 查看详细错误信息
dmesg | tail -20

4.10 使用模块绕开 GPL

4.10.1 问题背景

宋宝华在书中专门讨论了一个敏感话题:商业公司如何在 Linux 上发布闭源驱动

由于 Linux 内核采用 GPL v2 许可证,理论上所有与内核链接的代码都应该遵循 GPL。但现实中,一些硬件厂商(如 NVIDIA、某些 Wi-Fi 芯片厂商)出于商业利益,不愿意开放驱动源代码。

4.10.2 内核对闭源模块的态度

bash 复制代码
# 加载闭源模块时,内核会打印"污染"警告
sudo insmod proprietary_driver.ko
dmesg | tail -5
# [  100.123] proprietary_driver: module license 'Proprietary' taints kernel.
# [  100.124] Disabling lock debugging due to kernel taint
# [  100.125] proprietary_driver: module verification failed: signature and/or
#             required key missing - tainting kernel

# 查看内核污染状态
cat /proc/sys/kernel/tainted
# 4096   ← 非零表示内核已被污染

# 污染标志位含义(部分):
# Bit 0  (1)    ← 加载了专有模块
# Bit 1  (2)    ← 加载了强制加载的模块
# Bit 2  (4)    ← 不安全的 SMP 处理器
# Bit 9  (512)  ← 内核警告(WARN_ON)
# Bit 12 (4096) ← 加载了未签名的模块

4.10.3 常见的绕开 GPL 的技术手段

宋宝华在书中介绍了几种常见的技术手段,并指出其法律和技术风险:

方法一:用户空间驱动(User Space Driver)

将驱动的核心逻辑放在用户空间,内核只保留最小的接口层:

复制代码
用户空间驱动架构:

应用程序
    ↓
用户空间驱动库(闭源,.so 文件)← 核心逻辑在此,不受 GPL 约束
    ↓ 通过 UIO / VFIO / /dev/mem
内核空间(开源,GPL)← 只有最小的内核接口
    ↓
硬件
c 复制代码
/* 内核侧(开源,GPL):UIO 驱动 */
#include <linux/uio_driver.h>

static struct uio_info my_uio_info = {
    .name    = "my_device",
    .version = "1.0",
    .irq     = UIO_IRQ_CUSTOM,
};

/* 用户空间侧(可以闭源):通过 /dev/uio0 访问硬件 */
/*
int fd = open("/dev/uio0", O_RDWR);
void *base = mmap(NULL, 0x1000, PROT_READ|PROT_WRITE,
                  MAP_SHARED, fd, 0);
// 直接操作硬件寄存器
*(volatile uint32_t *)(base + 0x10) = 0x01;
*/
方法二:内核模块 + 用户空间库的分离架构
复制代码
分离架构:

┌─────────────────────────────────────────────────────┐
│                   用户空间                           │
│  ┌──────────────────────────────────────────────┐   │
│  │  闭源用户空间库(libmydriver.so)              │   │
│  │  包含:算法、协议、固件加载等核心逻辑           │   │
│  └──────────────────────┬───────────────────────┘   │
│                         │ ioctl / read / write       │
└─────────────────────────┼───────────────────────────┘
                          │
┌─────────────────────────┼───────────────────────────┐
│                   内核空间                           │
│  ┌──────────────────────▼───────────────────────┐   │
│  │  开源内核模块(GPL)                           │   │
│  │  只包含:内存映射、中断处理、DMA 等基础功能     │   │
│  └──────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘
方法三:EXPORT_SYMBOL 与 EXPORT_SYMBOL_GPL 的区别利用
c 复制代码
/*
 * 内核中的符号分为两类:
 *
 * EXPORT_SYMBOL:任何模块可用(包括闭源模块)
 * EXPORT_SYMBOL_GPL:只有 GPL 模块可用
 *
 * 闭源模块只能使用 EXPORT_SYMBOL 导出的符号
 * 不能使用 EXPORT_SYMBOL_GPL 导出的符号
 *
 * 随着内核版本演进,越来越多的核心符号改为 EXPORT_SYMBOL_GPL
 * 这使得闭源驱动越来越难以实现完整功能
 */

/* 闭源模块可以使用的符号(EXPORT_SYMBOL)*/
kmalloc()           /* 内存分配 */
kfree()             /* 内存释放 */
printk()            /* 内核打印 */
register_chrdev()   /* 注册字符设备 */

/* 闭源模块不能使用的符号(EXPORT_SYMBOL_GPL)*/
usb_register_driver()    /* USB 驱动注册 */
i2c_add_driver()         /* I2C 驱动注册 */
platform_driver_register() /* 平台驱动注册 */

4.10.4 宋宝华的观点与建议

宋宝华在书中明确表达了对开源的支持立场:

"Linux 内核社区的立场是:所有运行在内核空间的代码都应该遵循 GPL。使用技术手段绕开 GPL 在法律上存在风险,在技术上也会带来维护困难。"

开源驱动的优势

复制代码
1. 法律安全:完全符合 GPL 要求,无法律风险
2. 社区支持:可以合并进内核主线,由社区维护
3. 长期维护:内核 API 变化时,社区会帮助更新
4. 质量保证:经过社区代码审查,质量更高
5. 用户信任:用户可以审查代码,信任度更高

闭源驱动的劣势:
1. 法律风险:可能违反 GPL,面临法律诉讼
2. 维护困难:每次内核升级都需要重新适配
3. 调试困难:内核崩溃时无法获得社区帮助
4. 用户抵制:Linux 社区对闭源驱动持负面态度
5. 功能受限:无法使用 EXPORT_SYMBOL_GPL 的符号

实际案例

复制代码
NVIDIA 显卡驱动:
  - 长期使用闭源驱动(nvidia.ko)
  - 内核每次升级都需要重新编译
  - 2022年,NVIDIA 开始开放部分驱动源代码
  - 开源驱动(nouveau)功能逐渐完善

Wi-Fi 驱动:
  - 早期许多 Wi-Fi 芯片使用闭源驱动
  - 随着 GPL 压力增大,越来越多厂商开放源代码
  - Broadcom、Realtek 等厂商已提供开源驱动

本章小结

章节 核心知识点 关键 API / 命令
4.1 模块简介 模块的概念;为什么需要模块;模块 vs 应用程序的区别 insmodrmmodlsmod
4.2 程序结构 模块的六大组成部分;完整模块示例 #include <linux/module.h>
4.3 加载函数 __init 修饰符;返回值规范;goto 错误处理模式 module_init()__init__initdata
4.4 卸载函数 __exit 修饰符;资源逆序释放;devm_ 系列函数 module_exit()__exitdevm_kzalloc()
4.5 模块参数 module_param 类型;数组参数;sysfs 权限 module_param()MODULE_PARM_DESC()
4.6 导出符号 EXPORT_SYMBOL vs EXPORT_SYMBOL_GPL;符号依赖管理 EXPORT_SYMBOL()EXPORT_SYMBOL_GPL()
4.7 声明与描述 MODULE_XXX 宏;modinfo 命令;版本兼容性 MODULE_LICENSE()MODULE_AUTHOR()modinfo
4.8 使用计数 引用计数概念;.owner = THIS_MODULE 自动管理 try_module_get()module_put()
4.9 模块编译 单/多文件 Makefile;集成进内核源码树;常见编译错误 obj-mmake modulesdepmod
4.10 绕开 GPL 用户空间驱动;分离架构;开源 vs 闭源的利弊分析 UIOEXPORT_SYMBOL_GPL

内核模块开发的最佳实践

c 复制代码
/* 1. 始终声明 GPL 许可证 */
MODULE_LICENSE("GPL v2");

/* 2. 使用 static 限制函数作用域 */
static int __init my_init(void) { ... }
static void __exit my_exit(void) { ... }

/* 3. 加载函数使用 goto 错误处理 */
static int __init my_init(void)
{
    ret = step1(); if (ret) return ret;
    ret = step2(); if (ret) goto err2;
    ret = step3(); if (ret) goto err3;
    return 0;
err3: cleanup2();
err2: cleanup1();
    return ret;
}

/* 4. 卸载函数按逆序释放资源 */
static void __exit my_exit(void)
{
    cleanup3();
    cleanup2();
    cleanup1();
}

/* 5. 设置 .owner = THIS_MODULE */
static const struct file_operations my_fops = {
    .owner = THIS_MODULE,
    /* ... */
};

/* 6. 优先使用 devm_ 系列函数 */
dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL);

/* 7. 为模块参数提供合理的默认值和描述 */
static int timeout = 1000;
module_param(timeout, int, 0644);
MODULE_PARM_DESC(timeout, "超时时间(毫秒),默认1000");

参考文献:宋宝华《Linux设备驱动开发详解:基于最新的Linux 4.0内核》,机械工业出版社,2015年