【Linux内核模块】Linux内核模块程序结构

如果你已经写过第一个 "Hello World" 内核模块,可能会好奇:为什么那个几行代码的程序能被内核识别?那些module_init、MODULE_LICENSE到底是什么意思?今天咱们就来扒一扒内核模块的程序结构,搞清楚一个合格的内核模块到底由哪些部分组成,每个部分又承担着什么角色。​


目录

一、内核模块的 "骨架":最简化结构解析​

二、头文件:内核模块的 "说明书"​

2.1 最常用的三个头文件​

2.2 按需添加的其他头文件​

三、初始化函数:模块的 "出生证明"​

3.1 函数定义格式 

3.2 三个关键特性​

3.3 初始化函数里该做什么?​

四、退出函数:模块的 "临终遗言"​

4.1 函数定义格式 

4.2 三个关键特性​

4.3 退出函数的核心任务​

五、入口 / 出口声明:告诉内核 "从哪进,从哪出"​

5.1 声明格式 

5.2 为什么需要专门的声明?​

5.3 特殊情况:不支持卸载的模块​

六、许可证声明:模块的 "法律文件"​

6.1 常见的许可证类型​

6.2 不声明许可证会怎样?​

6.3 其他元信息声明​

七、模块参数:让模块更灵活的 "调节阀"​

7.1 定义模块参数的方法 

7.2  加载时传递参数​

7.3 参数权限说明​

7.4 支持的参数类型​

八、符号导出:模块间的 "共享工具"​

8.1 导出符号的方法 

8.2 其他模块如何使用导出的符号?​

8.3 符号导出的注意事项​

九、完整示例:包含所有结构的模块代码​

十、程序结构常见问题及解决方案​

10.1 初始化函数返回非 0 值会怎样?​

10.2 退出函数没释放资源会导致什么问题?​

10.3 模块参数为什么不能用局部变量?​

10.4 为什么有些函数不能被导出?​


一、内核模块的 "骨架":最简化结构解析​

先看一个能正常编译运行的最小内核模块代码,就像盖房子先搭框架,内核模块也有它的基础骨架:

// 必要的头文件
#include     // 包含模块初始化相关函数
#include   // 包含模块基本定义

// 模块加载时执行的函数
static int __init mymodule_init(void) {
    printk(KERN_INFO "模块加载成功!\n");
    return 0;  // 0表示初始化成功
}

// 模块卸载时执行的函数
static void __exit mymodule_exit(void) {
    printk(KERN_INFO "模块卸载成功!\n");
}

// 告诉内核哪个是入口函数,哪个是出口函数
module_init(mymodule_init);
module_exit(mymodule_exit);

// 模块许可证声明(必须有,否则内核会警告)
MODULE_LICENSE("GPL");

这个不到 20 行的代码包含了内核模块最核心的 5 个部分:

【Linux内核模块】Linux内核模块程序结构_第1张图片

这五个部分就像人体的五脏六腑,缺一不可。接下来逐个拆解,看看每个部分的具体作用。​

二、头文件:内核模块的 "说明书"​

和用户态程序一样,内核模块也需要头文件来获取函数声明和宏定义,但内核模块用的是内核自带的头文件,不是标准 C 库的。​

2.1 最常用的三个头文件​

  • linux/module.h:模块开发的 "圣经",包含了module_init、module_exit、MODULE_LICENSE等所有核心宏定义,没有它模块根本无法编译。​
  • linux/init.h:负责初始化相关的函数和宏,比如__init、__exit这些标记函数生命周期的关键字。​
  • linux/kernel.h:提供内核常用函数,比如最常用的printk日志输出函数就定义在这里。​

2.2 按需添加的其他头文件​

根据模块功能不同,还需要包含特定的头文件:​

  • 操作字符设备:linux/fs.h(文件系统相关定义)​
  • 内存分配:linux/slab.h(kmalloc函数所在)​
  • 网络操作:linux/net.h​
  • 硬件中断:linux/interrupt.h​

举个例子:如果你的模块需要分配内核内存,就必须包含linux/slab.h,否则编译器会报kmalloc未定义的错误。

三、初始化函数:模块的 "出生证明"​

初始化函数(init function)是模块被加载到内核时执行的第一个函数,相当于模块的 "出生仪式",负责完成模块的初始化工作。​

3.1 函数定义格式 

static int __init 函数名(void) {
    // 初始化操作
    return 0;  // 成功返回0,失败返回负的错误码(如-ENOMEM)
}

3.2 三个关键特性​

  • static关键字:限制函数只在当前模块内可见,避免和其他模块的函数重名冲突。内核里有上万个模块,重名可是大麻烦。​
  • __init宏:告诉内核 "这个函数只在模块加载时执行一次,执行完后就可以释放内存了"。内核会把所有带__init标记的函数放到专门的内存区域,初始化完成后就释放这部分内存,节省空间。​
  • 返回值规则:返回 0 表示初始化成功;返回负数表示失败,这个负数必须是内核定义的错误码(比如-ENOMEM表示内存不足,-EINVAL表示参数无效)。​

3.3 初始化函数里该做什么?​

初始化函数就像新房装修,要完成所有 "开张" 前的准备工作:​

  • 申请资源(内存、设备号、中断号等)​
  • 注册驱动(比如字符设备、网络协议等)​
  • 初始化数据结构​
  • 打印加载信息(方便调试)​

反面教材:不要在初始化函数里做耗时操作,比如长时间休眠或大量计算,这会导致模块加载很慢,甚至被内核判定为 "无响应"。​

四、退出函数:模块的 "临终遗言"​

退出函数(exit function)是模块被卸载时执行的函数,负责清理初始化函数申请的资源,相当于模块的 "后事处理"。​

4.1 函数定义格式 

static void __exit 函数名(void) {
    // 清理操作
}

4.2 三个关键特性​

  • static关键字:和初始化函数一样,限制函数作用域,避免命名冲突。​
  • __exit宏:告诉内核 "这个函数只在模块卸载时执行",内核会把它放到专门的内存区域,只有当模块支持卸载时才保留。​
  • 无返回值:因为卸载操作要么成功,要么导致内核崩溃(Oops),所以不需要返回值。​

4.3 退出函数的核心任务​

退出函数的工作就是 "undo" 初始化函数做的事情,遵循 "反向释放" 原则:​

  • 释放初始化时申请的内存(kfree、vfree)​
  • 注销初始化时注册的资源(设备号、中断等)​
  • 关闭打开的文件描述符​
  • 打印卸载信息​

重要原则:初始化时先申请的资源,退出时要后释放,就像穿衣服先穿内衣再穿外套,脱衣服要先脱外套再脱内衣。

// 正确的资源释放顺序示例
static int __init my_init(void) {
    // 步骤1:申请内存
    buf = kmalloc(1024, GFP_KERNEL);
    if (!buf) return -ENOMEM;
    
    // 步骤2:注册设备
    ret = register_chrdev(0, "mydev", &fops);
    if (ret < 0) {
        kfree(buf);  // 失败时释放已申请的内存
        return ret;
    }
    return 0;
}

static void __exit my_exit(void) {
    // 步骤1:先注销设备(后申请的先释放)
    unregister_chrdev(dev_num, "mydev");
    
    // 步骤2:再释放内存(先申请的后释放)
    kfree(buf);
}

五、入口 / 出口声明:告诉内核 "从哪进,从哪出"​

有了初始化函数和退出函数,还需要告诉内核这两个函数的存在,这就是module_init和module_exit的作用。​

5.1 声明格式 

module_init(初始化函数名);  // 告诉内核哪个是入口函数
module_exit(退出函数名);    // 告诉内核哪个是出口函数

5.2 为什么需要专门的声明?​

你可能会问:直接调用不行吗?为什么要多此一举?​

这是因为内核加载模块时,并不是直接调用函数名,而是通过符号表查找。module_init宏会初始化函数注册到内核的初始化函数链表中,当使用insmod加载模块时,内核会从这个链表中找到并执行它。​

打个比方:这就像你去参加会议,需要先在前台登记姓名和座位号(module_init),会议开始时(模块加载)工作人员就知道该叫谁上台了。​

5.3 特殊情况:不支持卸载的模块​

有些模块(比如关键的系统驱动)不希望被卸载,可以只定义初始化函数,不定义退出函数,这时module_exit可以省略。但这种情况很少见,大多数模块都应该支持卸载。​

六、许可证声明:模块的 "法律文件"​

MODULE_LICENSE看起来只是个简单的宏,实则关系到模块的 "合法性",是内核模块必须要有的声明。​

6.1 常见的许可证类型​

  • MODULE_LICENSE("GPL"):最常用的许可证,表明模块遵循 GPL 协议,这样才能使用内核中 GPL 许可的符号(函数和变量)。​
  • MODULE_LICENSE("GPL v2"):明确指定 GPL 版本 2。​
  • MODULE_LICENSE("Dual BSD/GPL"):双许可证,既可以按 BSD 许可,也可以按 GPL 许可。​
  • MODULE_LICENSE("Proprietary"):专有许可证,表明这是闭源模块,这时内核会限制它使用某些 GPL-only 的符号。​

6.2 不声明许可证会怎样?​

如果忘记写MODULE_LICENSE,模块加载时内核会打印警告: 

module: module license 'unspecified' taints kernel.

这不仅是警告,更重要的是:未声明 GPL 兼容许可证的模块,无法访问内核中标记为EXPORT_SYMBOL_GPL的符号,很多关键函数会用不了。​

6.3 其他元信息声明​

除了许可证,还有一些可选的元信息声明,它们不会影响模块功能,但能让模块信息更完整:​

  • MODULE_AUTHOR("你的名字"):模块作者​
  • MODULE_DESCRIPTION("模块功能描述"):简单说明模块用途​
  • MODULE_VERSION("1.0.0"):模块版本号​
  • MODULE_ALIAS("my_module"):模块的别名,方便modprobe查找​

这些信息可以通过modinfo命令查看,比如: 

modinfo hello.ko

会输出类似这样的信息:

filename:       /path/to/hello.ko
license:        GPL
author:         Your Name
description:    A simple hello module
version:        1.0.0

七、模块参数:让模块更灵活的 "调节阀"​

有时候我们希望模块加载时能动态配置一些参数,比如调试级别、缓冲区大小等,这时候就需要用到模块参数。​

7.1 定义模块参数的方法 

#include   // 必须包含这个头文件

// 定义参数:类型、变量名、默认值
static int debug_level = 0;  // 默认调试级别0
static char *device_name = "mydev";  // 默认设备名

// 声明参数(参数名,类型,权限)
module_param(debug_level, int, S_IRUGO);  // 整数类型,所有人可读
module_param(device_name, charp, S_IRUGO | S_IWUSR);  // 字符串,用户可写

// 可选:添加参数描述
MODULE_PARM_DESC(debug_level, "Debug level (0-3), default 0");
MODULE_PARM_DESC(device_name, "Device name, default 'mydev'");

7.2  加载时传递参数​

加载模块时可以通过命令行传递参数: 

sudo insmod hello.ko debug_level=2 device_name="testdev"

7.3 参数权限说明​

第三个参数是权限掩码,控制/sys/module/模块名/parameters/下对应文件的权限:​

  • S_IRUGO:所有人可读​
  • S_IWUSR:只有 root 用户可写​
  • 权限不能包含执行权限(S_IXUSR等),否则会被内核拒绝​

7.4 支持的参数类型​

除了int和charp(字符串指针),还支持这些类型:​

  • bool/invbool:布尔值(invbool表示取反)​
  • short/long/ulong:短整型 / 长整型 / 无符号长整型​
  • array:数组类型(需要指定元素类型和大小)​

数组参数的用法示例: 

static int arr[5];
module_param_array(arr, int, NULL, S_IRUGO);  // 第三个参数是实际传入的元素个数

八、符号导出:模块间的 "共享工具"​

有时候多个模块之间需要共享函数或变量,这时候就需要 "导出符号",让其他模块可以使用。​

8.1 导出符号的方法 

// 定义一个要共享的函数
void my_shared_function(int param) {
    // 函数实现
}
EXPORT_SYMBOL(my_shared_function);  // 导出符号,所有模块可使用

// 定义一个只能被GPL模块使用的函数
int my_gpl_function(void) {
    return 42;
}
EXPORT_SYMBOL_GPL(my_gpl_function);  // 只有GPL许可证的模块可使用

8.2 其他模块如何使用导出的符号?​

只需要在使用的模块中声明为extern即可: 

extern void my_shared_function(int param);  // 声明外部符号

// 在模块中直接调用
static int __init other_init(void) {
    my_shared_function(100);
    return 0;
}

8.3 符号导出的注意事项​

  • 导出的符号会增加模块间的依赖关系,被依赖的模块必须先加载​
  • 尽量少导出符号,过多的符号会增加模块耦合度​
  • EXPORT_SYMBOL_GPL能保证符号不被闭源模块使用,符合 GPL 协议​

可以通过cat /proc/kallsyms查看内核中所有导出的符号,包括内核本身和已加载模块的。​

九、完整示例:包含所有结构的模块代码​

讲了这么多理论,来整合一个包含所有结构的完整示例,看看实际代码中这些部分是如何组织的: 

// 1. 头文件包含
#include 
#include 
#include 
#include 
#include 

// 2. 模块参数定义
static int debug = 0;
static char *msg = "default message";
module_param(debug, int, S_IRUGO);
module_param(msg, charp, S_IRUGO | S_IWUSR);
MODULE_PARM_DESC(debug, "Debug level (0-3)");
MODULE_PARM_DESC(msg, "Message to print");

// 3. 导出符号(供其他模块使用)
static char *shared_buffer;
EXPORT_SYMBOL(shared_buffer);

static void print_debug_info(void) {
    if (debug >= 1) {
        printk(KERN_INFO "Debug: shared_buffer address = %p\n", shared_buffer);
    }
}
EXPORT_SYMBOL_GPL(print_debug_info);

// 4. 初始化函数
static int __init demo_init(void) {
    printk(KERN_INFO "demo module loaded: %s\n", msg);
    
    // 申请内存
    shared_buffer = kmalloc(1024, GFP_KERNEL);
    if (!shared_buffer) {
        printk(KERN_ERR "Failed to allocate memory\n");
        return -ENOMEM;
    }
    
    print_debug_info();
    return 0;
}

// 5. 退出函数
static void __exit demo_exit(void) {
    printk(KERN_INFO "demo module unloaded\n");
    kfree(shared_buffer);  // 释放内存
}

// 6. 入口/出口声明
module_init(demo_init);
module_exit(demo_exit);

// 7. 许可证及元信息
MODULE_LICENSE("GPL");
MODULE_AUTHOR("byte轻骑兵");
MODULE_DESCRIPTION("A demo module showing full structure");
MODULE_VERSION("1.0");

包含了我们前面讲的所有结构:头文件、初始化函数、退出函数、入口出口声明、许可证、参数、符号导出。编译后可以通过insmod加载,rmmod卸载,dmesg查看输出。​

十、程序结构常见问题及解决方案​

10.1 初始化函数返回非 0 值会怎样?​

初始化函数返回非 0(负的错误码)表示初始化失败,这时内核会:​

  • 不将模块加入已加载模块列表​
  • 自动调用模块的退出函数(如果定义了的话)​
  • 释放模块占用的内存​

所以初始化函数中一旦发生错误,必须返回正确的错误码,不能 "硬撑"。​

10.2 退出函数没释放资源会导致什么问题?​

最直接的后果是内存泄漏,如果多次加载卸载模块,泄漏的内存会越来越多。严重的情况下(比如没释放设备号),会导致设备无法再次使用,必须重启系统才能恢复。​

可以用lsmod命令查看模块的引用计数,如果卸载后模块仍然存在(Used by不为 0),很可能是资源没释放干净。​

10.3 模块参数为什么不能用局部变量?​

因为模块参数需要在整个模块生命周期中保持有效,局部变量在函数执行完后就会被释放,所以必须用全局变量(或static全局变量)来存储参数。​

10.4 为什么有些函数不能被导出?​

内核中有很多函数没有被导出(没有EXPORT_SYMBOL),意味着模块不能直接调用它们。这是内核开发者为了保证稳定性:不希望模块依赖内核内部的实现细节,因为这些细节可能在新版本中改变。​

如果确实需要调用未导出的函数,可以通过kallsyms_lookup_name动态查找,但这种方法不推荐,可能导致模块在 kernel 升级后失效。​


内核模块的程序结构就像一套严格的 "建筑规范",按照这个规范来写,才能保证模块的稳定性和兼容性。我们再来回顾一下核心要点:​

  1. 基础结构五要素:头文件、初始化函数、退出函数、入口出口声明、许可证,缺一不可。​
  2. 初始化与退出:遵循 "申请资源→使用资源→释放资源" 的生命周期,释放顺序与申请顺序相反。​
  3. 参数与符号:参数让模块更灵活,符号导出让模块间协作成为可能,但要注意权限和依赖。​
  4. 元信息:许可证决定了模块能使用哪些内核功能,其他元信息则方便管理和维护。​

掌握了这些结构知识,可以开始编写更复杂的模块了,比如字符设备驱动、简单的文件系统等。


如果在实践中遇到结构相关的问题,欢迎在评论区留言讨论,咱们一起解决!

你可能感兴趣的:(#,嵌入式Linux驱动开发实战,linux,运维,服务器)