内核模块开发:module_init 与模块机制详解

内核模块开发:module_init 与模块机制详解

内核模块是 Linux 驱动的载体。这篇深入讲模块的加载卸载机制、模块参数、符号导出、模块依赖、多文件编译,以及常见的模块开发技巧。

大家好,我是黒漂技术佬。

上一篇讲了驱动的基本概念和最简单的 hello world 模块。这篇深入内核模块:加载卸载的完整流程、模块参数怎么传、怎么导出符号给其他模块用、模块依赖、多文件模块怎么编译。

把模块机制搞清楚,后面写驱动才有基础。


一、模块加载和卸载的完整流程

insmod 做了什么?

bash 复制代码
insmod hello.ko

背后做了这些事:

  1. 读取 .ko 文件:ELF 格式的目标文件
  2. 分配内核内存:把模块代码和数据加载到内核空间
  3. 重定位:修正符号地址,链接到内核
  4. 解析未定义符号:找内核导出的函数(比如 printk),填上地址
  5. 执行 module_init 函数:调用我们写的初始化函数
  6. 加入模块链表:内核记录这个模块已加载

rmmod 做了什么?

bash 复制代码
rmmod hello
  1. 检查引用计数:有没有其他模块在用?在用就不让卸载
  2. 执行 module_exit 函数:调用清理函数
  3. 释放资源:释放模块占用的内核内存
  4. 从模块链表移除

查看模块信息

bash 复制代码
# 查看已加载模块
lsmod

# 查看模块详细信息
modinfo hello.ko

# 查看模块依赖
modprobe --show-depends hello

二、__init 和 __exit 标记

__init

c 复制代码
static int __init hello_init(void) { ... }

标记初始化函数,特点:

  • 模块加载完之后,这段内存会被释放掉(只执行一次,后面不用了)
  • 节省内核内存,嵌入式很重要
  • 只在初始化阶段用的函数和数据都可以加 __init / __initdata

__exit

c 复制代码
static void __exit hello_exit(void) { ... }

标记退出函数,特点:

  • 只在模块卸载时用
  • 如果内核编译成不支持模块卸载(CONFIG_MODULES=n),这段代码直接不编译进去
  • 节省空间

其他相关标记

c 复制代码
__initdata    // 初始化数据,用完释放
__initconst   // 初始化常量
__devinit     // 设备初始化(老内核用,新内核不用了)

三、模块参数(module_param)

加载模块时可以传参数,类似命令行参数。

基本用法

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

static int baudrate = 9600;
module_param(baudrate, int, 0644);
MODULE_PARM_DESC(baudrate, "串口波特率,默认9600");

static char *port_name = "ttyS0";
module_param(port_name, charp, 0644);
MODULE_PARM_DESC(port_name, "端口名称");

加载时传参:

bash 复制代码
insmod hello.ko baudrate=115200 port_name=ttyS1

参数类型

类型宏 对应 C 类型 说明
byte unsigned char 字节
short short 短整型
ushort unsigned short 无符号短整型
int int 整型
uint unsigned int 无符号整型
long long 长整型
ulong unsigned long 无符号长整型
charp char* 字符串指针
bool bool 布尔值(0/1, y/n)
invbool bool 反转的布尔

权限

第三个参数是 sysfs 中的权限:

  • 0644:root 可写,所有人可读
  • 0444:只读
  • 0:不在 sysfs 显示

加载后可以在 /sys/module/hello/parameters/ 下看到参数。

数组参数

c 复制代码
static int irq_list[4] = {0};
static int irq_count = 0;
module_param_array(irq_list, int, &irq_count, 0644);

传参:

bash 复制代码
insmod hello.ko irq_list=10,20,30

四、导出符号(EXPORT_SYMBOL)

一个模块可以导出函数或变量,给其他模块用。

导出符号

c 复制代码
// module_a.c
int my_add(int a, int b)
{
    return a + b;
}
EXPORT_SYMBOL(my_add);   // 导出给所有模块用

另一个模块使用

c 复制代码
// module_b.c
extern int my_add(int a, int b);

static int __init b_init(void)
{
    printk("1+2=%d\n", my_add(1, 2));
    return 0;
}

EXPORT_SYMBOL vs EXPORT_SYMBOL_GPL

说明
EXPORT_SYMBOL 所有模块都能用,不管什么许可证
EXPORT_SYMBOL_GPL 只有 GPL 许可证的模块能用

内核里大部分函数都是 GPL 导出的,所以你的模块要加 MODULE_LICENSE("GPL") 才能用。

查看导出的符号

bash 复制代码
# 内核所有导出的符号
cat /proc/kallsyms | grep my_add

# 模块导出的
modinfo module_a.ko

五、模块依赖

模块 A 用了模块 B 导出的符号,那 A 依赖 B。

modprobe 自动处理依赖

bash 复制代码
# 会自动先加载依赖的模块
modprobe module_a

前提是模块要放在正确位置,并且执行过 depmod 生成依赖关系文件。

insmod 不会自动加载依赖

bash 复制代码
insmod module_a.ko  # 会失败,提示 unknown symbol
# 得手动先加载依赖
insmod module_b.ko
insmod module_a.ko

声明依赖

代码里可以声明:

c 复制代码
MODULE_ALIAS("别名");
MODULE_SOFTDEP("pre: module_b");  // 软依赖

一般不用手动写,depmod 会自动分析。


六、多文件模块

一个模块可以由多个 .c 文件编译成一个 .ko。

Makefile 写法

makefile 复制代码
obj-m := mymodule.o

# mymodule 由多个文件组成
mymodule-objs := main.o util.o hw.o

编译出来还是一个 mymodule.ko,但内部是多个 .o 链接的。

目录结构

复制代码
mymodule/
├── main.c     // 模块入口、file_operations
├── util.c     // 工具函数
├── util.h
├── hw.c       // 硬件操作
├── hw.h
└── Makefile

大一点的驱动都是多文件组织的。


七、模块的状态信息

MODULE_ 系列宏

c 复制代码
MODULE_LICENSE("GPL");           // 许可证(必须)
MODULE_AUTHOR("Heipiao");        // 作者
MODULE_DESCRIPTION("xxx 驱动");  // 描述
MODULE_VERSION("1.0");           // 版本
MODULE_ALIAS("xxx");             // 别名
MODULE_DEVICE_TABLE(i2c, xxx_id); // 设备表(总线驱动用)

查看模块信息

bash 复制代码
modinfo hello.ko

输出类似:

复制代码
filename:       hello.ko
description:    Hello World Module
author:         Heipiao
license:        GPL
srcversion:     ...
depends:
vermagic:       5.4.0 SMP mod_unload ARM
parm:           baudrate:int

MODULE_LICENSE 的重要性

  • 没加或者不是 GPL,很多内核函数用不了(unknown symbol 错误)
  • 影响内核的「污染」状态,非 GPL 模块加载内核会标记为 tainted
  • 商业驱动可以用 MODULE_LICENSE("Proprietary"),但只能用非 GPL 导出的函数

八、内核打印:printk 详解

日志级别

c 复制代码
printk(KERN_EMERG   "紧急消息\n");   // 0 最高
printk(KERN_ALERT   "警报\n");       // 1
printk(KERN_CRIT    "严重错误\n");   // 2
printk(KERN_ERR     "错误\n");       // 3
printk(KERN_WARNING "警告\n");       // 4
printk(KERN_NOTICE  "注意\n");       // 5
printk(KERN_INFO    "信息\n");       // 6
printk(KERN_DEBUG   "调试\n");       // 7 最低

控制台显示级别

控制台只显示级别高于某个值的消息:

bash 复制代码
# 查看当前控制台级别
cat /proc/sys/kernel/printk
# 7  4  1  7
# 当前控制台级别 / 默认消息级别 / 最低控制台级别 / 默认启动级别

数字越小级别越高。控制台级别是 4 的话,只有 0-3 级(错误及以上)才会打印到控制台,其他的用 dmesg 看。

pr_ 系列宏

更方便的封装:

c 复制代码
pr_err("错误: %d\n", err);
pr_warn("警告\n");
pr_info("信息\n");
pr_debug("调试\n");

比 printk 短,自动加级别。驱动里推荐用 pr_ 系列或者 dev_ 系列。

dev_ 系列(设备驱动用)

跟具体设备绑定的打印,会自动带上设备名:

c 复制代码
dev_err(dev, "初始化失败\n");
dev_info(dev, "probe成功\n");

字符设备、平台设备驱动里常用,方便定位是哪个设备出的问题。


九、常见模块操作命令

加载

bash 复制代码
insmod xxx.ko          # 简单加载,不处理依赖
modprobe xxx           # 加载,自动处理依赖
modprobe -r xxx        # 卸载

卸载

bash 复制代码
rmmod xxx              # 卸载
modprobe -r xxx        # 卸载,连带卸载没用的依赖

查看

bash 复制代码
lsmod                  # 列出所有已加载模块
modinfo xxx.ko         # 模块信息
cat /proc/modules      # 模块列表
cat /sys/module/xxx/   # 模块的 sysfs 目录

依赖管理

bash 复制代码
depmod                 # 生成模块依赖关系
depmod -a              # 扫描所有模块

十、模块开发注意事项

1. 初始化失败要回滚

初始化过程中某一步失败了,前面分配的资源要释放掉:

c 复制代码
static int __init xxx_init(void)
{
    int ret;
    
    ret = step1();
    if (ret) return ret;
    
    ret = step2();
    if (ret) goto err_step1;
    
    ret = step3();
    if (ret) goto err_step2;
    
    return 0;

err_step2:
    undo_step2();
err_step1:
    undo_step1();
    return ret;
}

内核驱动里 goto 很常用,用来做错误回滚。

2. 退出函数要清理干净

module_exit 里要把 init 里申请的所有资源都释放:

  • 注销设备
  • 释放内存
  • 释放中断
  • 注销定时器
  • 取消工作队列

漏了就内存泄漏,甚至内核 panic。

3. 静态函数和变量

模块内部的函数和变量尽量 static,避免污染内核命名空间。

4. 注意并发

初始化一般是单线程的,但驱动跑起来后可能多核并发访问,后面讲并发同步的时候细说。

5. 版本魔数(vermagic)

.ko 文件里有版本信息,跟内核不匹配会加载失败:

复制代码
insmod: ERROR: could not insert module xxx.ko: Invalid module format

原因:编译用的内核版本、配置跟运行的不一致。必须用对应开发板的内核源码来编译。


十一、完整的模块示例

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

static int count = 1;
module_param(count, int, 0644);
MODULE_PARM_DESC(count, "打印次数,默认1");

static char *message = "Hello";
module_param(message, charp, 0444);
MODULE_PARM_DESC(message, "打印的消息");

static int __init hello_init(void)
{
    int i;
    for (i = 0; i < count; i++) {
        pr_info("%s, Kernel! (%d/%d)\n", message, i+1, count);
    }
    return 0;
}

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

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Heipiao");
MODULE_DESCRIPTION("带参数的Hello模块");
MODULE_VERSION("1.0");

测试:

bash 复制代码
insmod hello.ko count=3 message=Hi
dmesg
# Hi, Kernel! (1/3)
# Hi, Kernel! (2/3)
# Hi, Kernel! (3/3)

rmmod hello

十二、本篇小结

  • 模块加载:insmod 读取 .ko → 分配内存 → 重定位 → 解析符号 → 执行 module_init
  • __init / __exit:标记初始化和退出代码,优化内存占用
  • 模块参数:module_param 传参,支持多种类型,加载时可配置
  • 导出符号:EXPORT_SYMBOL / EXPORT_SYMBOL_GPL,模块之间共享函数
  • 模块依赖:modprobe 自动处理,insmod 要手动按顺序加载
  • 多文件模块:xxx-objs 指定多个 .o,编译成一个 .ko
  • printk 有 8 个日志级别,推荐用 pr_ / dev_ 系列宏
  • 初始化失败要回滚,退出要清理干净,注意资源释放
  • 内核版本不匹配会加载失败(vermagic 错误)

下一篇讲字符设备驱动:file_operations、open/read/write 的实现,以及和用户空间的数据交互。

我是黒漂技术佬。

相关推荐
AFinalStone2 小时前
Android 7系统网络(二)内核层—netfilter_iptables与路由策略
android·网络
ICGOODFIND12 小时前
2026物联网模组选型指南:U-BLOX定位模组+SIMCOM芯讯通主流蜂窝通信模组参数与场景适配
嵌入式硬件·物联网·硬件工程·iot
糖果店的幽灵2 小时前
混沌测试从入门到精通1
网络·混沌测试
阿拉斯攀登2 小时前
字符设备驱动:file_operations 与读写实现
linux·嵌入式·linux驱动·字符设备驱动
汤愈韬2 小时前
EVPN协议概述
网络
阿拉斯攀登2 小时前
嵌入式 Linux 驱动概述:分类、内核模块、开发环境
linux·嵌入式硬件·linux内核·嵌入式·linux驱动
2401_854151553 小时前
STM32 位带操作——像 51 一样操作 GPIO
单片机·嵌入式硬件
zlinear数据采集卡12 小时前
从0到1硬核拆解:工业级数据采集卡的隔离设计与Modbus通信实战
arm开发·单片机·嵌入式硬件·fpga开发·开源
Uncertainty!!12 小时前
Ubuntu 22.04 安装超好用中文输入法rime-ice(雾凇拼音)过程记录
linux·ubuntu·中文输入法