[个人总结] Linux设备驱动第三版:2.建立和运行模块-II

五、内核符号表

5.1 核心定义:内核符号表

内核符号表是 Linux 内核维护的一张全局「地址映射清单」,核心存储内容为:

  • 内核自身实现的全局函数、全局变量的内存地址;
  • 已加载内核模块主动导出全局函数、全局变量的内存地址。

这些可被外部访问的函数 / 变量,统称为「内核符号」,内核符号表就是所有内核符号的统一索引库。

5.2 核心作用:模块加载时的「符号解析」

这是内核符号表最基础、最核心的作用,也是我们前文提到的 insmod 核心工作逻辑:

5.2.1 完整流程

当使用 insmod 加载模块时,模块代码中一定会存在未定义的外部符号 (比如调用内核的printkvmalloc等函数),此时:

  1. insmod 会读取模块中的「未定义符号」列表;
  2. 内核符号表中查找这些符号对应的「内存地址」;
  3. 将找到的地址填充到模块的对应位置,完成符号解析与地址绑定
  4. 绑定完成后,模块才能正式在核内运行。

5.2.2 加载失败的关联场景

如果内核符号表中没有找到模块需要的符号 ,会触发「unresolved symbols(未解析符号)」错误,insmod 直接加载失败;而 modprobe 之所以能解决这个问题,是因为它会自动查找「定义了该缺失符号」的依赖模块,先加载依赖模块(让符号加入内核符号表),再加载目标模块。

5.2.3 核心规则

内核模块只能调用 / 使用「内核符号表中存在的符号」,无任何例外,这是内核的强制约束。

5.3 核心作用:模块的「符号导出」

这是内核符号表的反向核心能力,也是实现模块联动的关键,所有规则必记,无例外

5.3.1 黄金默认规则

内核模块加载后,其内部定义的所有函数、变量,默认不会被导出 ,也不会加入内核符号表

模块自身的函数 / 变量,默认仅能自己内部调用,外部模块完全不可见、不可使用;绝大多数模块不需要导出任何符号,因为模块的核心使命是「注册自身服务内核」,完成独立功能即可,这是内核开发的常态。

5.3.2 符号导出的「唯一必要性」

只有一种场景需要主动导出符号:当其他内核模块,需要调用当前模块的函数 / 变量时

5.3. 导出后的核心变化

当模块主动导出符号后,该符号会被永久加入内核符号表,成为全局可用的内核符号;后续所有加载的模块,都可以直接调用这个符号,直到该模块被卸载(卸载时,导出的符号会从内核符号表中移除)。

5.4 核心应用:模块堆叠(Module Stacking)

5.4.1 模块堆叠的定义

基于「模块符号导出」实现的 模块分层依赖、按需堆叠调用 ,称为「模块堆叠」。简单理解:A 模块导出符号 → B 模块加载时,使用 A 模块导出的符号 → B 模块「堆叠」在 A 模块之上,形成上层模块依赖下层模块的层级关系。

模块堆叠的核心支撑,就是内核符号表:下层模块导出符号入表,上层模块从表中读取符号完成调用。

5.4.2 经典堆叠案例

Linux 内核源码中大量使用模块堆叠设计,这是内核解耦的核心思路,典型案例:

  1. 文件系统:msdos 文件系统模块 → 依赖 fat 模块导出的符号,堆叠在fat模块之上;
  2. 外设驱动:USB 输入设备模块 → 依赖 usbcore(USB 核心模块)+ 输入子系统模块 导出的符号,堆叠在二者之上;
  3. 视频驱动:video-for-linux 系列驱动 → 分为「通用视频模块」+「硬件专属驱动模块」,通用模块导出统一接口符号,硬件模块堆叠其上,适配不同视频硬件;
  4. 外设子系统:并口、USB 内核子系统,均采用「核心层模块导出符号 + 设备层模块堆叠调用」的设计。

5.4.3 模块堆叠的核心优势

模块堆叠是处理复杂驱动工程的最优设计思路,核心价值:

  1. 代码解耦:将「通用逻辑」和「硬件专属逻辑」拆分为不同模块,通用模块只写一次,多个硬件模块复用即可;
  2. 分层实现:核心层负责提供统一接口,业务层负责适配具体硬件 / 功能,代码更简洁、易维护、易扩展;
  3. 按需加载:只加载当前系统需要的模块,无需加载冗余功能,节省内核内存资源。

5.5 核心联动知识点

5.5.1 模块卸载

如果一个模块导出了符号,且该符号正在被其他堆叠的模块使用 → 内核判定该模块「被占用」,使用 rmmod 卸载时会直接失败;只有当所有依赖它的模块都被卸载后,该模块才能被正常卸载。

5.5.2 版本 + 平台依赖

符号导出 / 解析的能力,同样受「版本依赖 + 平台依赖」约束:

  1. 导出符号的模块,和使用符号的模块,必须编译自「相同内核版本、相同平台配置」;
  2. 内核符号表中的符号,会绑定内核的版本 / 平台信息,不匹配的符号无法被正常解析。

5.5.3 内核符号表的特性

内核符号表是内核内存中的动态清单:加载模块可能新增符号,卸载模块会移除对应符号,内核运行期间实时变化。

5.6 总结

5.6.1 核心速查表

核心概念 核心规则 / 作用
内核符号表 内核维护的全局地址清单,存内核 + 导出的模块符号(函数 / 变量)
符号解析 模块加载时,insmod 查符号表绑定地址,无符号则加载失败
符号导出 模块内符号「默认不导出」,仅需给其他模块调用时才导出,导出后入符号表
模块堆叠 基于符号导出的模块依赖,上层模块用下层模块的导出符号,内核主流设计思路
卸载约束 被其他模块依赖的导出模块,无法被 rmmod 卸载

5.6.2 核心总结

  • 内核符号表是模块加载的核心:模块靠它解析符号,内核靠它管理全局可用的函数 / 变量;
  • 模块符号「默认不导出」,无需导出是常态,仅需被其他模块调用时才导出;
  • 模块堆叠是符号导出的核心应用,是内核解耦复杂驱动的最优方式,内核源码大量使用;
  • 符号导出会形成模块依赖,被依赖的模块无法被卸载,所有规则与前文无缝联动。

六、预备知识

6.1 核心必写:模块源码「强制包含的头文件」

内核模块是运行在内核态的特殊代码,必须通过内核提供的专属头文件获取「函数定义、数据结构、内核符号」,这部分是硬性要求,分为「✅ 强制必包含」和「✅ 高频常用」两类:

6.1.1 2 个「强制必包含」头文件(所有模块必须写在源码开头)

复制代码
#include <linux/module.h>  // 核心头文件,无它不可编译成模块
#include <linux/init.h>    // 初始化/退出函数的专属依赖头文件
  • linux/module.h:提供了加载内核模块所需的所有核心函数、符号、宏定义,是模块的基础依赖,没有该头文件,模块代码无法被内核编译系统识别;
  • linux/init.h:专门提供「模块初始化函数、模块退出函数」的相关定义与声明,对应我们前文hello例子中的hello_init()(初始化)和hello_exit()(退出),是模块生命周期的核心依赖。

6.1.2 1 个「高频常用」头文件(几乎所有模块都会加)

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

作用:让模块支持「加载时传递参数 」(对应前文 提到的insmod命令行传参特性),是内核模块开发的高频需求,后续会专门讲解该头文件的使用,属于必加项。

6.2 模块「许可声明宏 MODULE_LICENSE ()」

6.2.1 语法格式(固定写法,必须写在所有函数之外)

复制代码
MODULE_LICENSE("GPL");  // 最常用,推荐写法

该宏的作用:显式声明当前模块代码遵循的开源许可协议 ,是内核对模块的「合规性硬性要求」,建议紧跟头文件书写

6.2.2 内核「官方认可的合法许可值」

内核能识别的许可协议有固定清单,只有填写以下值才会被内核认定为「合规许可」,其余均无效:"GPL""GPL v2""GPL and additional rights""Dual BSD/GPL""Dual MPL/GPL""Proprietary"(专有闭源)。

✅ 开发首选:"GPL" ------ 适配 GNU 通用公共许可的任意版本,兼容性最强。

6.2.3 核心规则:不声明 / 声明无效 → 内核「被污染」

这是内核的重要规则,也是联动第 1 章「许可条款」的核心点:

  • 如果模块不写该宏,或填写了内核不识别的许可内容 → 内核会默认该模块是「私有闭源模块」;
  • 加载这类私有模块后,内核状态会被标记为 tainted(被污染)
  • 后果:内核官方开发者,不会为「内核被污染后出现的系统问题」提供任何技术帮助 / 支持,这是内核开源社区的硬性准则。

核心结论:MODULE_LICENSE ("GPL") 是必写项,无任何妥协理由,是内核模块开发的合规底线。

6.3 模块「描述性宏定义」

内核提供了一系列以 MODULE_ 开头的宏,作用是为模块添加「元信息描述」,无任何编译强制要求,但是能让模块的信息更完整、更易维护,也是内核开发的通用规范。

6.3.1 所有可用的描述宏 + 核心作用

所有宏均写在「函数之外的任意位置」,无顺序要求,功能互不影响:

  • MODULE_AUTHOR("作者名/邮箱"):声明模块的编写者;

  • MODULE_DESCRIPTION("模块功能描述"):用易懂的文字说明模块的作用(如 "简单的 hello 内核模块");

  • MODULE_VERSION("版本号"):声明模块的代码修订版本(版本号规范参考linux/module.h注释);

  • MODULE_ALIAS("别名"):声明模块的其他名称,方便内核识别;

  • MODULE_DEVICE_TABLE(...):告知用户空间「该模块支持哪些硬件设备」。

6.3.2 原文补充说明

  • MODULE_ALIAS 的具体用法 → 第 11 章讲解;
  • MODULE_DEVICE_TABLE 的具体用法 → 第 12 章讲解;
  • 无需记忆用法,后续用到再深入即可。

6.3.3 内核通用编写惯例

所有 MODULE_xxx 系列宏(包括必写的MODULE_LICENSE、可选的描述宏),推荐统一写在源码文件的末尾 → 这是内核代码的通用风格,让代码结构更整洁(头文件在开头、业务代码在中间、模块描述在末尾)。

6.4 核心总结:模块源码「最简完整骨架」

结合本节所有内容,所有内核模块的源码都遵循这个最简骨架,无任何例外,这是编写模块的基础模板,直接复制套用即可,也是下一节编写实际代码的核心基础:

复制代码
// 第一步:导入必写头文件
#include <linux/module.h>
#include <linux/init.h>
// 可选:加载传参需求,导入头文件
#include <linux/moduleparam.h>

// 第二步:编写模块的初始化函数(后续讲解)
static int __init hello_init(void)
{
    // 模块加载时执行的逻辑
    return 0;
}

// 第三步:编写模块的退出函数(后续讲解)
static void __exit hello_exit(void)
{
    // 模块卸载时执行的逻辑
}

// 第四步:注册初始化/退出函数(后续讲解)
module_init(hello_init);
module_exit(hello_exit);

// 第五步:必写+可选描述宏,统一放文件末尾(内核惯例)
MODULE_LICENSE("GPL");          // 必写,合规底线
MODULE_AUTHOR("your name");     // 可选,声明作者
MODULE_DESCRIPTION("Hello Kernel Module"); // 可选,功能描述

6.5 总结

6.5.1 核心速查表

内容分类 具体内容 状态 核心作用
头文件 #include <linux/module.h> 强制必加 模块核心依赖,提供函数 / 符号定义
头文件 #include <linux/init.h> 强制必加 初始化 / 退出函数的专属依赖
头文件 #include <linux/moduleparam.h> 高频可选 模块加载时传递参数
核心宏 MODULE_LICENSE("GPL") 强制必写 声明开源许可,避免内核被污染
描述宏 MODULE_AUTHOR/ DESCRIPTION 等 可选推荐 补充模块元信息,规范代码
书写惯例 MODULE_xxx 宏放文件末尾 推荐遵循 内核通用风格,代码更整洁

6.5.2 核心总结

  1. 内核模块源码有固定骨架:2 个必加头文件 + 1 个必写许可宏,是所有模块的基础;
  2. MODULE_LICENSE ("GPL") 是合规底线,不写会导致内核被污染,无官方技术支持;
  3. 各类模块描述宏推荐放文件末尾,遵循内核编码惯例;
  4. 本节内容是编写实际模块代码的前置准备,记住骨架即可直接套用。

七、初始化和关停

7.1 模块「初始化函数」

7.1.1 核心使命

模块被insmod/modprobe加载时,唯一执行的入口函数,核心工作:

① 向内核注册本模块提供的所有功能(硬件驱动、软件抽象、/proc 文件、加密转换等);

② 为模块运行申请内核资源(内存、寄存器、设备号等);

③ 完成模块自身的内部初始化逻辑。简单说:初始化函数的作用是「告诉内核,我准备好了,这是我能提供的功能」。

7.1.2【固定标准写法】必遵语法格式

复制代码
// 固定模板,所有初始化函数都遵循该格式
static int __init 模块初始化函数名(void)
{
    // 初始化逻辑:注册功能、申请资源等
    return 0; // 返回0 = 初始化成功;返回负数 = 初始化失败
}
// 强制必写宏:将上述函数注册为模块的初始化入口
module_init(模块初始化函数名);

7.1.3 各关键字 / 修饰符的核心作用

每一个修饰符都是内核规范,缺一不可 / 各有价值,按重要性排序:

1. static 静态声明:推荐必加,因为初始化函数仅在当前模块文件内生效,无需对外可见;内核无强制要求,但这是内核编码的通用规范,避免符号污染。

2. __init 初始化修饰符 :✅ 可选但强烈推荐,内核核心优化标识,作用是:

  • 告诉内核「该函数仅在模块加载初始化阶段使用,初始化完成后不再调用」;
  • 内核加载模块成功后,会自动释放该函数占用的内存,将内存回收做其他用途,节省内核宝贵的内存资源。

补充:对应的数据优化标识 __initdata,修饰「仅初始化阶段使用的变量」,同样会被内核释放内存。

3.module_init(...) 注册宏 :✅ 强制必写,无它不可运行

  • 该宏会在模块的目标代码中添加「特殊段标识」,告诉内核「这是模块的初始化入口函数」;
  • 不写该宏 → 内核找不到初始化函数,模块加载后不会执行任何初始化逻辑,完全无效。

7.1.4 初始化的核心操作:内核「注册函数」的使用

模块要向内核提供功能,必须调用内核提供的专用注册函数 ,所有注册函数均以 register_ 为前缀(如register_chrdev注册字符设备),核心规则:

  • 注册函数的入参,通常是「描述功能的结构体指针 + 功能名称」;结构体中会包含模块的函数指针,内核就是通过这些指针调用模块的功能;
  • 可注册的功能类型极多:字符设备、块设备、文件系统、/proc 文件、加密转换、串口、USB 设备等,既包含硬件驱动,也包含纯软件抽象功能;
  • 注册的本质:让内核的全局管理体系,「感知并收录」当前模块的功能,后续内核 / 应用程序才能调用该模块。

7.2 模块「清理函数(关停函数)」

7.2.1 核心使命

模块被rmmod卸载时,唯一执行的入口函数,核心工作:

① 按初始化的逆序 ,注销初始化函数中注册的所有功能

② 释放初始化函数中申请的所有内核资源 (内存、设备号、锁等);简单说:清理函数的作用是「告诉内核,我要退出了,归还所有我占用的资源,注销所有我提供的功能」,是内核内存安全、资源安全的底线

7.2.2 硬性规则

除了纯测试的试验性模块,所有正式模块必须定义清理函数 → 内核会直接禁止「无清理函数的模块」被卸载,这是强制约束。

7.2.3 固定标准写法

复制代码
// 固定模板,所有清理函数都遵循该格式
static void __exit 模块清理函数名(void)
{
    // 清理逻辑:注销功能、释放资源(按初始化逆序执行)
}
// 强制必写宏:将上述函数注册为模块的清理入口
module_exit(模块清理函数名);

7.2.4 各关键字 / 修饰符的核心作用

1.static 静态声明:同初始化函数,推荐必加,规范编码,避免符号污染。

2.void 无返回值 :清理函数只负责释放资源,无需返回状态,固定声明为void

3.__exit 卸载修饰符 :✅ 可选但强烈推荐,内核核心优化标识,作用是:

  • 告诉内核「该函数仅在模块卸载阶段使用」;编译器会将该函数放入内核的特殊 ELF 段;
  • 如果模块被直接编译进内核(非可加载模块),或内核配置为「禁止模块卸载」,内核会自动丢弃该函数,节省内存;
  • 注意:__exit修饰的函数只能被内核卸载逻辑调用,不能被初始化函数的错误分支调用(后文错误处理重点)。

4.module_exit(...) 注册宏 :✅ 强制必写,无它不可卸载

  • 该宏告诉内核「这是模块的清理入口函数」;不写该宏 → 内核找不到清理函数,模块无法被卸载。

7.2.5 清理的黄金准则

清理函数中,必须按照初始化的「逆序」执行注销 / 释放操作

例:初始化时先注册 A、再注册 B、最后注册 C → 清理时先注销 C、再注销 B、最后注销 A。

原因:内核的注册功能存在依赖关系,正序注册、逆序注销能避免资源引用混乱,是内核开发的「铁律」。

7.3 初始化中的错误处理

7.3.1 初始化的「错误处理」

(1)核心前提

模块的初始化过程一定会出现失败的情况 :申请内存失败、注册功能失败、申请设备号失败等,这些都是内核开发的常态;如果不对错误做处理 ,会导致「资源申请成功但功能注册失败」「部分功能注册成功但无法使用」,最终造成内核资源泄漏、内核状态不稳定,甚至系统死机

原文核心观点:不做错误处理的初始化函数,是不合格的内核代码

(2)核心原则

如果初始化在「某一步失败」,必须:

  1. 立刻终止后续的初始化逻辑;
  2. 把失败前已经成功申请的资源、注册的功能,全部释放 / 注销
  3. 返回一个合理的错误码,告知用户失败原因;→ 简单说:初始化失败,必须「回滚所有已完成的操作」,让内核回到初始化前的干净状态

(3)内核提供的「错误码规则」

Linux 内核的错误码有固定规范,无任何例外:

  1. 错误码是负数 ,所有错误码均定义在头文件 <linux/errno.h> 中;
  2. 常用错误码:-ENOMEM(内存分配失败)、-ENODEV(设备不存在)、-EINVAL(参数无效)等;
  3. 初始化函数返回错误码后,用户可通过系统日志查看具体失败原因,是调试的核心依据。

7.3.2 方案 1:goto 跳转法 错误处理

这是Linux 内核源码中最主流的错误处理方式 ,核心优势:代码简洁、执行高效、逻辑清晰,完美适配「初始化步骤少、逻辑简单」的场景,也是内核开发者的首选。

(1)核心逻辑

  • 每完成一个「注册 / 申请」操作,就判断是否成功;失败则通过goto跳转到对应的「回滚标签」;
  • 所有回滚标签按「初始化逆序」排列,只回滚当前步骤前已经成功的操作;
  • 所有错误最终统一返回错误码。

(2)固定代码模板(直接套用,原文示例)

复制代码
int __init my_init(void)
{
    int err; // 存储错误码
    // 步骤1:注册功能A
    err = register_this(ptr1, "name1");
    if (err)  // 失败则跳转回滚
        goto fail_this;
    // 步骤2:注册功能B
    err = register_that(ptr2, "name2");
    if (err)  // 失败则回滚步骤2+步骤1
        goto fail_that;
    // 步骤3:注册功能C
    err = register_those(ptr3, "name3");
    if (err)  // 失败则回滚步骤3+步骤2+步骤1
        goto fail_those;
    
    return 0; // 所有步骤成功,返回0

// 回滚标签:按初始化逆序排列,只释放已成功的资源
fail_those:
    unregister_that(ptr2, "name2"); // 回滚步骤2
fail_that:
    unregister_this(ptr1, "name1"); // 回滚步骤1
fail_this:
    return err; // 返回错误码,告知失败原因
}

(3)内核态度

开发者通常抵触goto,但在内核错误处理中,合理的 goto 是最优解:它能避免大量嵌套的 if-else,让回滚逻辑一目了然,是内核编码的通用规范。

7.3.3 方案 2:「调用清理函数」错误处理

针对「初始化步骤多、注册 / 申请项繁杂」的场景,goto法会产生大量回滚标签和重复代码,此时最优解是:定义一个通用的清理函数,初始化失败时直接调用该函数完成所有回滚,原文重点讲解该方案。

(1)核心逻辑

  1. 定义一个无__exit修饰的通用清理函数,内部对「所有可能申请的资源 / 注册的功能」做条件判断释放(有申请则释放,无则跳过);
  2. 初始化函数中任何一步失败,直接goto到统一的失败标签,调用清理函数完成回滚;
  3. 模块卸载时的module_exit,也调用这个通用清理函数。

(2)固定代码模板(直接套用,原文示例)

复制代码
// 全局变量:记录资源申请/注册的状态
struct xxx *res1 = NULL;
struct yyy *res2 = NULL;
int reg_ok = 0; // 标记是否注册成功

// 通用清理函数:无__exit修饰!可被初始化错误分支调用
void my_cleanup(void)
{
    if (res1) release_res1(res1); // 有申请则释放
    if (res2) release_res2(res2); // 有申请则释放
    if (reg_ok) unregister_func();// 有注册则注销
}

// 初始化函数
int __init my_init(void)
{
    int err = -ENOMEM; // 默认错误码:内存不足
    // 申请资源
    res1 = alloc_res1();
    res2 = alloc_res2();
    if (!res1 || !res2) // 资源申请失败
        goto fail;
    // 注册功能
    err = register_func(res1, res2);
    if (err) // 注册失败
        goto fail;
    reg_ok = 1; // 标记注册成功
    return 0; // 成功

fail:
    my_cleanup(); // 调用清理函数,统一回滚所有操作
    return err;
}

// 卸载时,调用同一个清理函数
static void __exit my_exit(void)
{
    my_cleanup();
}
module_exit(my_exit);

(3)关键注意点

如果清理函数需要被「初始化的错误分支调用」,则绝对不能给该清理函数加__exit修饰符 → 加了__exit的函数,内核会做内存优化,可能在初始化阶段就被释放,调用会直接导致内核崩溃。

7.4 模块加载竞争

这是内核开发的高级重点 ,也是新手最容易踩坑的点,原文专门强调:模块加载时的竞争问题,可能直接威胁整个系统的稳定性

7.4.1 竞争产生的根本原因

内核是多任务并发运行的环境,存在一个致命的时序问题:

你的初始化函数,刚完成第一个功能的注册 ,内核的其他部分(进程、中断、其他模块)立刻就有可能调用该功能 ,甚至你的初始化函数还在运行中,模块的功能就已经被内核调用了。

7.4.2 两条「黄金避坑准则」

(1)先完成内部初始化,再对外注册功能

所有模块自身的内部准备工作(变量初始化、资源申请、状态设置)必须全部完成,最后再调用 register_xxx 注册功能;绝对不能「先注册,后初始化」,否则内核调用时,模块的内部逻辑还未就绪,会直接崩溃。

(2)谨慎处理「初始化失败但已被调用」的场景

如果初始化函数在「部分功能注册成功后」发现错误,想要终止初始化,但此时内核已经在使用该模块的功能 → 这种情况极其危险。

  • 最优解:尽量不要让这种模块的初始化失败(模块已经能提供有效功能,无需退出);
  • 必须失败:则要等待内核的所有调用操作完成后,再注销功能、释放资源,避免数据混乱。

7.5 总结

7.5.1 内核模块通用骨架源码

复制代码
// 必加头文件
#include <linux/module.h>
#include <linux/init.h>
#include <linux/errno.h> // 错误码依赖

// 初始化函数:带goto错误处理,内核首选
static int __init my_init(void)
{
    int err;
    // 步骤1:申请资源/注册功能
    err = register_xxx();
    if (err) goto fail1;
    // 步骤2:申请资源/注册功能
    err = register_yyy();
    if (err) goto fail2;

    printk("模块初始化成功!\n");
    return 0;

fail2:
    unregister_xxx(); // 回滚步骤1
fail1:
    printk("模块初始化失败,错误码:%d\n", err);
    return err;
}

// 清理函数:逆序注销,释放所有资源
static void __exit my_exit(void)
{
    unregister_yyy();
    unregister_xxx();
    printk("模块卸载成功!\n");
}

// 注册入口宏
module_init(my_init);
module_exit(my_exit);

// 必写许可声明+可选描述宏
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("初始化与清理示例模块");

7.5.2 核心速查表

内容分类 核心要点 硬性要求
初始化函数 static int __init 函数名 (void),return 0 = 成功 / 负数 = 失败 必写
module_init 注册初始化入口,无则初始化逻辑不执行 必写
__init 修饰符 初始化后释放函数内存,优化内存 推荐必加
清理函数 static void __exit 函数名 (void),逆序注销 / 释放 必写
module_exit 注册清理入口,无则模块无法卸载 必写
__exit 修饰符 卸载专用,内核禁止卸载则丢弃函数 推荐必加
错误处理 失败必须回滚所有已完成操作,内核首选 goto 法 必做
竞争情况 先内部初始化,后对外注册功能 必遵

7.5.3 核心总结

  1. 模块的生命周期:加载执行「初始化函数」(注册 + 申请),卸载执行「清理函数」(注销 + 释放),二者必写、必配对;

  2. 初始化失败要回滚,内核首选 goto 跳转法,复杂场景用通用清理函数,核心是「不残留任何资源」;

  3. __init/__exit 是优化标识,能节省内存,清理函数被初始化调用则不能加__exit;

  4. 加载竞争是大坑,核心准则是「先内部准备,后对外注册」,避免内核提前调用未就绪的功能;

  5. 所有规则都是内核稳定的底线,无任何妥协空间,是内核开发的基础规范。

八、模块参数

8.1 核心作用

8.1.1 核心作用

内核模块(尤其是驱动)往往需要适配不同的硬件环境、不同的系统配置 ,部分运行参数会因环境不同而变化;模块参数的作用就是:在模块加载时,动态为模块传递配置参数,灵活调整模块行为,彻底摆脱「硬编码参数→编译→加载」的死板流程。

8.2 典型使用场景

  1. 指定硬件的I/O端口地址、I/O内存地址(适配老旧硬件必备);
  2. 指定驱动使用的设备号、功能开关(如是否开启 DMA、是否启用命令队列);
  3. 配置模块的运行逻辑(如打印次数、输出内容);
  4. 所有「需要根据环境动态调整」的模块配置项,均适合用模块参数实现。

8.3 直观示例(原文核心案例)

hello模块改造为hellop,新增 2 个参数:整型howmany(打印次数)、字符串whom(打印对象),加载时传参即可动态控制打印内容:

bash 复制代码
insmod hellop.ko howmany=10 whom="Mom"

加载后模块会打印 hello, Mom 共 10 次,无需修改任何代码。

8.2 单个模块参数的声明与使用

8.2.1 核心前提

模块要支持传参,必须提前在源码中声明参数 :通过内核提供的 module_param 宏完成声明,该宏定义在 <linux/moduleparam.h> 中,所有声明必须写在「函数之外」(全局 / 静态区域),通常放在源码开头、头文件之后。

8.2.2 定义参数变量

首先定义需要接收参数的变量,必须遵循 2 个规则

  1. 变量类型与要接收的参数类型一致;
  2. 必须给变量设置默认值(insmod 仅在用户显式传参时修改值,不传参则使用默认值);
  3. 推荐声明为static(静态),避免符号污染,内核无强制要求但为通用规范。

示例变量定义:

复制代码
static char *whom = "world";  // 字符串参数,默认值"world"
static int howmany = 1;        // 整型参数,默认值1

8.2.3 声明模块参数

复制代码
// 核心宏格式:无任何变体,直接套用
module_param(变量名, 参数类型, 权限掩码);

宏的 3 个参数详解:

1. 变量名 :要绑定的参数变量名(如howmanywhom),必须和定义的变量名完全一致;

2. 参数类型 :内核支持的固定类型,原文完整罗列,全部收录,记常用即可,无自定义类型(特殊类型可扩展,无需深究):

  • bool/invbool:布尔值(对应 int 变量),invbool是「值反转」(传 true 实际为 false);
  • charp:字符指针(字符串),内核会自动为用户传入的字符串分配内存,指针直接指向该内存;
  • int/long/short:有符号整型;uint/ulong/ushort:无符号整型;
  • ✅ 开发最常用:intcharp,占 99% 的使用场景。

3. 权限掩码 :控制参数在sysfs文件系统中的访问权限,核心规则见下文「三、权限掩码核心规则」,开发最常用值:S_IRUGO

示例声明(与变量定义配套,完整可用):

bash 复制代码
module_param(howmany, int, S_IRUGO);   // 整型参数,只读权限
module_param(whom, charp, S_IRUGO);    // 字符串参数,只读权限

8.3 权限掩码的「黄金规则」

这是本节最重要的细节规则 ,也是新手最容易踩坑的点,所有内容均为原文重点强调,无任何冗余,必须深刻理解

8.3.1 权限掩码的来源与本质

  • 权限值是内核定义的宏,全部在头文件 <linux/stat.h> 中,无需手动定义,直接调用即可;
  • 权限掩码的作用:控制该参数是否在sysfs虚拟文件系统中生成对应的访问节点,以及该节点的读写权限。

8.3.2 核心规则

(1)规则①:权限掩码设为 0 → 推荐首选,最常用

  • 效果:内核不会在 sysfs 中生成任何该参数的访问文件,该参数仅能在「模块加载时通过 insmod/modprobe 传参配置」,加载后无法修改;
  • 优势:安全、高效,无任何后续修改风险,99% 的模块参数都应设置为 0

(2)规则②:权限掩码非 0 → 生成 sysfs 访问节点

  • 效果:模块加载后,会在路径 /sys/module/模块名/parameters/ 下,生成一个与参数同名的文件;该文件的读写权限,就是设置的权限掩码值;
  • 例如:S_IRUGO → 文件为「所有人只读」,S_IRUGO|S_IWUSR → 文件为「所有人只读,root 用户可写」。

(3)规则③:可写参数的「致命坑点」

如果给参数设置「可写权限」(如S_IRUGO|S_IWUSR),用户通过sysfs修改该文件内容后:

  • ✅ 模块内对应的参数变量值,会自动同步更新
  • ❌ 内核不会给模块发送任何修改通知,模块完全不知道参数值被改了!

结论:除非你的模块代码专门做了「参数变更检测 + 逻辑适配」,否则绝对不要给参数设置可写权限,极易导致模块运行逻辑混乱、系统异常。

8.3.3 开发常用权限值

  • S_IRUGO:所有人可读,加载后不可修改 → 开发首选,通用值
  • S_IRUGO | S_IWUSR:所有人可读,root 可写 → 仅在必须动态修改参数时使用。

8.4 「数组类型」的模块参数

内核支持传递数组类型的参数 (如多个整型、多个字符串),参数值用逗号分隔 即可;声明数组参数需要使用专属宏 module_param_array,是单个参数的扩展,语法固定,直接套用即可。

8.4.1 固定语法格式

复制代码
// 步骤1:定义数组变量+默认值,指定数组长度
static int arr[5] = {1,2,3}; // 数组长度5,默认前3个值为1,2,3
static int arr_num;          // 用来接收:用户实际传入的数组元素个数

// 步骤2:声明数组参数,核心宏
module_param_array(数组名, 元素类型, 接收个数的变量名, 权限掩码);

8.4.2 宏的 4 个参数详解

  1. 数组名:要绑定的数组变量名(如arr);
  2. 元素类型:数组内元素的类型(如int,仅支持单个参数的所有类型);
  3. 接收个数的变量名:整型变量,内核会自动将「用户实际传入的元素个数」写入该变量;
  4. 权限掩码:同单个参数,推荐S_IRUGO0

8.4.3 核心规则

内核会自动校验传入的元素个数:如果传入的个数超过数组定义的长度,模块加载者会直接拒绝加载,报错提示,避免数组越界。

8.4.4 加载传参示例

复制代码
insmod mod_arr.ko arr=10,20,30,40

加载后,arr_num会被赋值为 4,数组arr的前 4 个元素变为 10,20,30,40。

8.5 模块参数的「加载传参方式」

声明参数后,即可在加载模块时传递参数值,两种方式均有效,按需选择:

8.5.1 insmod 命令行直接传参(最常用,核心方式)

语法:insmod 模块名.ko 参数名1=值1 参数名2=值2 ...

注意:字符串参数如果包含空格 / 特殊字符,需要用双引号包裹(如whom="My Mom");数组参数用逗号分隔,无空格。✅ 原文示例:insmod hellop.ko howmany=10 whom="Mom"

8.5.2 modprobe 配置文件传参

modprobe 除了支持命令行传参,还可以从配置文件 /etc/modprobe.conf 中读取参数值,适合「需要固定参数加载」的场景,无需每次手动敲参数。

8.6 内核模块参数的「开发黄金规范」

所有规则均为内核开发通用规范,是写出健壮模块的基础:

  1. 所有参数必须设置默认值:insmod/modprobe 仅在用户显式传参时修改值,不传参则用默认值,绝对不能定义无默认值的参数;
  2. 参数变量推荐声明为 static:避免全局符号污染,内核无强制要求,但为编码规范;
  3. 参数合法性校验(必做) :模块加载时,在初始化函数中检查参数值是否合理(如howmany不能是负数、硬件地址不能超出范围),非法值则直接返回错误码,终止加载; 示例:if (howmany < 0) return -EINVAL;
  4. 优先设置权限掩码为 0:除非必须动态修改,否则关闭 sysfs 访问,杜绝参数被意外修改的风险;
  5. 参数命名简洁直观 :如io_addrirq_numprint_count,见名知意。

8.7 示例:带参数的 hellop 模块源码

整合本节所有内容 + 前文的初始化 / 清理函数,形成完整可运行的模块源码,这是内核模块参数的标准模板,直接套用即可:

bash 复制代码
// 必加头文件
#include <linux/module.h>
#include <linux/init.h>
#include <linux/moduleparam.h> // 模块参数核心头文件
#include <linux/errno.h>

// 1. 定义参数变量+默认值
static char *whom = "world";
static int howmany = 1;

// 2. 声明模块参数
module_param(howmany, int, S_IRUGO);
module_param(whom, charp, S_IRUGO);

// 3. 初始化函数:使用参数,做合法性校验
static int __init hellop_init(void)
{
    int i;
    // 参数合法性校验:打印次数不能为负
    if (howmany < 0)
        return -EINVAL;
    // 按参数值打印内容
    for (i = 0; i < howmany; i++)
        printk(KERN_INFO "hello, %s!\n", whom);
    return 0;
}

// 4. 清理函数
static void __exit hellop_exit(void)
{
    printk(KERN_INFO "bye, %s!\n", whom);
}

// 注册入口宏
module_init(hellop_init);
module_exit(hellop_exit);

// 必写许可声明
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("模块参数示例模块");

8.8 总结

8.8.1 核心速查表

核心内容 语法 / 规则 核心要点
单个参数声明 module_param(name, type, perm) 3 个参数,type 支持 int/charp/bool 等
数组参数声明 module_param_array(name, type, num, perm) num 记录传入元素个数,内核校验长度
必加头文件 #include <linux/moduleparam.h> 无则无法使用参数宏
权限掩码 0 → 无 sysfs 节点,推荐 S_IRUGO = 所有人只读,可写需做变更检测
传参方式 insmod mod.ko a=1 b="str" 字符串带引号,数组用逗号分隔
开发规范 参数必设默认值 + 合法性校验 非法值直接返回错误码,终止加载

8.8.2 核心总结

  1. 模块参数是「加载时动态配置模块」的核心能力,无需改代码编译,极大提升适配性,必备头文件是 moduleparam.h;
  2. 单个参数用 module_param,数组用 module_param_array,语法固定,声明必须在函数外,变量必设默认值;
  3. 权限掩码优先设 0,其次 S_IRUGO,绝对不要随便开可写权限,无通知会导致逻辑混乱;
  4. 初始化函数中必须做参数合法性校验,非法值直接退出,是内核稳定的底线;
  5. 传参方式简单,insmod 命令行直接传即可,这是驱动开发的高频实用功能。

九、在用户空间做

9.1 用户空间设备驱动

完全在用户态编写的硬件控制程序,无需编写内核模块,直接通过用户态程序完成对硬件的初始化、读写、控制等操作;本质是「绕过内核态的纯用户态硬件操作」,可以理解为「运行在用户空间的驱动程序」。

关键补充:用户态驱动并非完全脱离内核 → 绝大多数用户态驱动,都需要内核提供的「底层通用内核驱动」做支撑(如 SCSI 通用内核驱动),才能间接访问硬件,无法彻底脱离内核独立运行。

9.2 用户空间驱动的「六大好处」

这是用户态驱动的全部核心价值,也是所有适合选择用户态开发的理由,原文重点强调,所有优势均为「内核态驱动不具备 / 难以实现」的特性,优先级从高到低:

9.2.1 开发成本极低,功能灵活:可链接完整的 C 标准库

用户态程序能直接调用所有 C 库函数(printf、malloc、文件操作等),驱动可独立完成复杂逻辑,无需依赖外部工具程序;而内核态驱动完全无 C 库可用,所有功能都要基于内核原生接口实现,开发难度翻倍。

9.2.2 调试极其便捷,零门槛排错

用户态驱动可直接使用 gdb普通调试器调试,断点、单步、变量查看都和普通程序一致;而内核态驱动的调试需要专用内核调试工具,流程复杂、门槛极高,是内核开发的核心痛点。

9.2.3 安全无风险,崩溃不影响系统:「进程级隔离」

如果用户态驱动卡死 / 崩溃,只需用kill命令杀掉对应的进程即可,绝对不会导致整个系统死机 / 卡死;唯一例外是硬件本身故障。而内核态驱动一旦出错,会直接污染内核空间,大概率导致系统蓝屏、死机,无任何挽回余地。

9.2.4 内存资源友好:用户内存支持内存交换(swap)

内核态内存是「常驻物理内存」,一旦分配就无法被交换到硬盘,会永久占用宝贵的 RAM;而用户态驱动的内存属于进程内存,不使用时会被内核自动交换到硬盘,释放物理内存给其他程序使用,尤其适合「不常用但体积大」的硬件驱动。

9.2.5 支持设备并行访问,功能不打折

精心设计的用户态驱动,能和内核态驱动一样,实现对硬件的多进程并行访问;典型实现是「单进程接管硬件,多客户端连接使用」,完全满足多任务需求。

9.2.6 闭源友好,规避两大核心痛点

如果需要编写闭源商业驱动,用户态是最优选择:

① 规避 GPL 开源协议的模糊许可问题(内核态驱动必须遵循 GPL,闭源会合规风险);

② 规避内核接口变动的适配成本(内核版本迭代会修改接口,内核态驱动需重新编译适配;用户态驱动接口稳定,几乎无需修改)。

9.3 典型实现形式 + 经典应用案例

用户态驱动并非凭空实现,内核提供了多种底层支撑方式,原文列举了行业主流的实现形式和成熟案例,全部为实际开发中的真实场景,无需深究原理,了解即可:

9.3.1 实现形式 1:基于内核「通用底层驱动」做上层封装(主流)

内核提供了一些「通用硬件抽象驱动」,将硬件的底层操作接口暴露给用户态,用户态程序基于这些接口封装业务逻辑,无需直接操作硬件寄存器;这是用户态驱动的核心实现方式。

9.3.2 实现形式 2:「服务器进程 + 客户端」代理模式

编写一个独立的用户态服务进程,独占接管硬件的所有操作;其他应用程序作为「客户端」,通过进程间通信连接该服务进程,间接操作硬件。该模式天然支持并行访问,是复杂设备的最优用户态实现。

9.3.3 经典案例

  1. USB 设备:libusb 开源库(用户态 USB 驱动)、内核中的gadgetfs
  2. 图形显示:X 服务器(用户态图形驱动,接管显卡硬件,为所有 X 客户端提供图形资源);
  3. 外设硬件:SANE 包(用户态 SCSI 扫描器驱动)、cdrecord(用户态光盘刻录机驱动);

补充趋势:X 服务器正逐步转向「内核帧缓冲驱动」做底层支撑,X 服务器仅作为上层服务,真正的显卡硬件操作由内核态驱动完成 ------核心硬件最终都会回归内核态驱动

9.4 用户空间驱动的「七大致命缺点」

这是本节的核心重点,内核开发必考必懂 !所有缺点均为「用户态的天然限制,无完美解决方案」,也是为什么 99% 的核心设备驱动必须运行在内核态 的根本原因。原文明确强调:用户态驱动的缺陷远大于优势,这些硬伤直接决定了它的适用边界,优先级从「致命硬伤」到「性能缺陷」排序,无任何冗余:

9.4.1 无法使用硬件中断

用户态程序完全没有权限、也没有接口响应硬件中断;硬件中断是外设的核心工作机制(如网卡收包、硬盘读写完成、按键触发),无中断则无法实现硬件的实时响应。

补充:仅 IA32 架构有vm86系统调用的兼容方案,适配性极差,无实际开发价值,等同于无解。

9.4.2 DMA 直接内存访问受限严重

用户态程序只能通过「内存映射 /dev/mem」的方式实现 DMA,且仅限 root 特权用户才能操作;DMA 是高速硬件的核心需求(显卡、网卡、硬盘),这种方式效率极低、权限受限,完全无法满足高性能需求。

9.4.3 硬件 I/O 端口访问门槛高、效率低

用户态程序要访问硬件的 I/O 端口,必须先调用iopermiopl系统调用申请权限;且存在三大问题:

① 不是所有硬件平台都支持这两个系统调用(兼容性差);

② 访问速度极慢,远不如内核态直接访问;

③ 必须是特权用户才能调用,普通用户无权限。

9.4.4 响应速度慢,存在不可控的延迟

用户态程序与硬件之间传递数据 / 指令,需要多次内核态↔用户态的上下文切换,每次切换都有性能开销,导致响应延迟;而内核态驱动无切换开销,可直接操作硬件,响应速度是用户态的百倍以上。

9.4.5 内存交换导致的响应超时

用户态内存支持 swap 交换,若驱动程序被内核交换到硬盘,此时访问硬件会出现不可接受的超长延迟 ;虽可通过mlock系统调用锁定内存不被交换,但mlock仅限特权用户使用,且用户态程序依赖大量库代码,需要锁定的内存页过多,实操性极低。

9.4.6 无法处理内核核心设备

用户态驱动完全无法实现对 Linux 核心设备的驱动开发,包括但不限于:网络接口、块设备(硬盘、U 盘)、字符设备中的核心外设等;这些设备是操作系统的基石,必须由内核态驱动接管。

9.4.7 大部分操作需要 root 特权

用户态驱动的硬件访问、内存映射、端口操作等核心功能,都需要 root 用户权限才能执行,普通用户无任何操作权限;而内核态驱动可通过权限管理,让普通用户也能访问硬件。

9.5 内核态驱动 / 用户态驱动 「核心选型原则」

这是本节所有内容的最终落脚点,也是内核开发者的核心决策依据,无任何例外,原文明确给出的最优实践,优先级从高到低,覆盖所有开发场景:

9.5.1 优先选择「用户空间驱动」的情况

仅在满足全部条件时,才适合用用户态实现,这是用户态的唯一适用边界:

  1. 开发初期:初次接触全新的未知硬件 → 先写用户态程序调试硬件逻辑,无需担心内核崩溃、系统卡死,安全高效;
  2. 硬件特性:非核心外设、无需硬件中断、无需 DMA、对响应速度无要求(如简单传感器、低速串口设备);
  3. 开发需求:有闭源商业需求、想规避 GPL 许可问题、想降低开发 / 调试成本;
  4. 权限容忍:可接受 root 特权用户操作,无需开放给普通用户。

9.5.2 必须选择「内核空间驱动」的情况

只要满足任一条件,就必须编写内核态驱动,无任何备选方案:

  1. 硬件特性:需要硬件中断、需要 DMA、对响应速度有高要求(如网卡、显卡、硬盘、USB 高速设备);
  2. 设备类型:核心系统设备(网络接口、块设备、字符核心设备);
  3. 权限需求:需要开放给普通用户使用,无特权限制;
  4. 性能需求:需要高性能、低延迟、无上下文切换开销。

9.5.3 最优过渡方案

先用户态调试,再内核态封装对全新硬件,先在用户空间编写程序,摸清硬件的寄存器操作、数据交互逻辑、工作流程,规避内核调试的风险;等硬件逻辑完全成熟、稳定后,再将核心逻辑封装成内核模块,迁移到内核态运行。→ 这是兼顾开发效率与最终性能的最优解,内核开发者的通用实践!

9.6 总结

9.6.1 内核态驱动 VS 用户态驱动 核心差异

对比维度 内核空间驱动 用户空间驱动
C 库支持 ❌ 无任何 C 库可用 ✅ 完整 C 标准库调用
调试难度 ❌ 极高,需内核调试工具 ✅ 极低,普通调试器即可
崩溃风险 ❌ 直接导致系统死机 / 蓝屏 ✅ 仅进程崩溃,杀进程即可恢复
内存占用 ❌ 常驻物理内存,不可交换 ✅ 内存可 swap,闲置释放 RAM
硬件中断 ✅ 原生完美支持 ❌ 完全不支持(硬伤)
DMA 访问 ✅ 原生高效支持 ❌ 仅内存映射,特权受限,效率低
I/O 端口访问 ✅ 直接访问,无开销 ❌ 需系统调用,慢且特权受限
响应速度 ✅ 极致快,无上下文切换 ❌ 慢,上下文切换开销大
核心设备支持 ✅ 支持所有设备(网络、块设备等) ❌ 不支持核心设备,仅限外设
权限要求 ✅ 可开放给普通用户 ❌ 大部分操作需要 root 特权
闭源友好度 ❌ 受 GPL 约束,接口变动需适配 ✅ 规避许可问题,接口稳定

9.6.2 核心总结

  1. 用户态驱动是「低门槛、高安全」的备选方案,核心优势是开发调试简单、崩溃无系统风险、闭源友好,但存在无法弥补的硬缺陷;

  2. 核心硬伤:无中断、DMA 受限、I/O 端口访问差,这些缺陷直接决定「核心硬件必须内核态驱动」;

  3. 选型核心:新手调试新硬件→用户态,成熟后→内核态;核心设备 / 高性能 / 需中断→内核态,简单外设 / 闭源需求→用户态;

  4. 本节核心结论:用户态驱动是绝佳的过渡方案,但内核态驱动是所有核心硬件的最终归宿,这也是本书的核心定位 ------ 讲解内核态驱动开发。

相关推荐
HLC++11 小时前
Linux的进程间通信
android·linux·服务器
华清远见IT开放实验室12 小时前
实验室建设案例 | 石家庄科技信息职业学院嵌入式实验室——从底层硬件到系统应用,一所应用型高校的嵌入式人才培养这样落地
linux·arm开发·stm32·嵌入式硬件·高校·实验室建设
groundhappy15 小时前
idalib安装和codex ida-mcp配置
linux·开发语言·python
通信小小昕15 小时前
Ubuntu 26.04 中文输入法安装
linux·运维·ubuntu
张小姐的猫16 小时前
【Linux】网络编程 —— HTTP协议(上)
linux·运维·服务器·网络·http·单例模式·策略模式
栩栩云生17 小时前
AI 写代码犯的错,早被写进了错题集
linux·安全·ai编程
imc.1118 小时前
linux基础IO
linux·运维·服务器
BelongPanda20 小时前
Linux Nginx 纯手动 Let‘s Encrypt 泛域名证书配置教程
linux·nginx
酷可达拉斯20 小时前
Linux操作系统-shell编程(0)
linux·运维·服务器·python·云计算
2301_7779983420 小时前
Linux中断机制:操作系统如何高效运行
linux·运维·服务器