ESC
输入关键词搜索文章标题和内容

Linux内核模块开发:从Hello World到模块间通信

本文由 linuxROS 整理发布,首发于 linuxros.cn,转载请注明出处。

Linux内核模块开发:从Hello World到模块间通信

内核模块是Linux驱动开发的基石。理解模块的加载卸载机制、参数传递、符号导出和依赖管理,是写好驱动的第一步。本文基于Linux 6.8内核(Ubuntu 24.04 LTS),从最简模块出发,逐步覆盖模块参数、符号导出、版本控制和模块间通信。

一、内核模块是什么?

内核模块是一段可以在内核运行时动态加载和卸载的代码,扩展内核功能不需要重新编译内核或重启系统。
| 对比 | 内核模块 | 内核内建代码 |
|:-------|:---------|:-------------|
| 加载时机 | 运行时动态加载 | 编译时链接进内核映像 |
| 内存占用 | 按需加载,不用时卸载释放 | 始终常驻内存 |
| 开发调试 | 修改后insmod即可验证 | 需重新编译内核并重启 |
| 发布形式 | .ko文件独立分发 | 绑定内核版本发布 |
| 适用场景 | 设备驱动、文件系统、网络协议 | 核心调度器、内存管理 |

典型使用场景:
- 设备驱动:USB、I2C、SPI等外设驱动,硬件不确定时用模块最灵活
- 文件系统:ext4、FUSE等,按需挂载
- 网络协议栈扩展:Netfilter模块、自定义协议

二、最简内核模块

Hello World模块

// hello.c - 最简内核模块(Linux 6.8验证:#include <linux/module.h>
#include <linux/init.h>

/* 模块元信息*/
MODULE_LICENSE("GPL");
MODULE_AUTHOR("linuxros");
MODULE_DESCRIPTION("Hello World内核模块");
MODULE_VERSION("1.0");

/* 初始化函数:insmod时执行*/
static int __init hello_init(void)
{
    printk(KERN_INFO "hello: module loaded\n");
    return 0;
}

/* 退出函数:rmmod时执行*/
static void __exit hello_exit(void)
{
    printk(KERN_INFO "hello: module unloaded\n");
}

module_init(hello_init);
module_exit(hello_exit);

关键宏说明

作用 说明
MODULE_LICENSE("GPL") 声明许可证 必须声明,否则内核会打印tainted警告,且无法使用GPL-only符号
MODULE_AUTHOR() 声明作者 纯信息,不影响功能
MODULE_DESCRIPTION() 模块描述 modinfo可查看
MODULE_VERSION() 版本号 用于版本控制,modinfo可查看

module_init告诉内核加载模块时调用哪个函数,module_exit告诉内核卸载时调用哪个函数。__init标记的函数在初始化完成后内存会被回收,__exit标记的函数在内建代码中会被编译器丢弃。

printk与日志级别

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 - 最低优先级

控制台只显示优先级低于console_loglevel的消息(数值越小优先级越高)。用dmesg可以查看所有级别的内核日志。开发驱动时常用pr_info、pr_err、pr_warn等快捷宏,等价于printk(KERN_XXX ...)。

三、Makefile与构建

外部模块Makefile

# Makefile - 外部内核模块构建
obj-m := hello.o

KDIR := /lib/modules/$(shell uname -r)/build

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

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

KDIR指向当前运行内核的构建目录。M=$(PWD)告诉kbuild在指定目录下查找模块源码。obj-m表示编译为模块(obj-y则是内建)。

安装内核头文件

Ubuntu 24.04 LTS默认内核。.8系列,安装头文件。

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

常用命令

命令 作用 示例
make 编译模块 生成hello.ko
insmod 加载模块 sudo insmod hello.ko
rmmod 卸载模块 sudo rmmod hello
lsmod 查看已加载模块 lsmod | grep hello
modinfo 查看模块信息 modinfo hello.ko
dmesg 查看内核日志 dmesg | tail -5
modprobe 智能加载(处理依赖) sudo modprobe hello

insmod和modprobe的区别:insmod只加载指定模块,遇到依赖会报错;modprobe自动解析并加载依赖模块。

四、模块参数

模块参数允许在加载时传入配置值,也可以通过sysfs在运行时修改。

基本参数定义

// param_demo.c - 模块参数示例
#include <linux/module.h>
#include <linux/init.h>
#include <linux/moduleparam.h>

/* 定义参数 */
static int count = 1;
static char *name = "default";
static bool enable = false;
static int values[4] = {1, 2, 3, 4};
static int nvalues = 4;  // 数组实际元素个数

/* 声明参数 */
module_param(count, int, S_IRUGO);
module_param(name, charp, S_IRUGO);
module_param(enable, bool, S_IRUGO);
module_param_array(values, int, &nvalues, S_IRUGO);

MODULE_PARM_DESC(count, "迭代次数(默认。);
MODULE_PARM_DESC(name, "设备名称(默认default。);
MODULE_PARM_DESC(enable, "使能开关(默认false。);
MODULE_PARM_DESC(values, "数值数组);

static int __init param_init(void)
{
    int i;
    pr_info("param: count=%d name=%s enable=%d\n", count, name, enable);
    pr_info("param: values=");
    for (i = 0; i < nvalues; i++)
        pr_cont("%d ", values[i]);
    pr_cont("\n");
    return 0;
}

static void __exit param_exit(void)
{
    pr_info("param: module unloaded\n");
}

module_init(param_init);
module_exit(param_exit);

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("模块参数示例");

参数类型与权限

类型 关键字 说明
整数 int 32位有符号整数
布尔 bool 0。,invbool取反
字符串 charp 内核自动分配内存存储字符串
整数数组 array module_param_array,需传入长度变量指针

权限标志位
| 权限 | | 含义 |
|:-----|:---|:-----|
| S_IRUGO | 0444 | 所有用户可读(sysfs中可见) |
| S_IWUSR | 0200 | 仅root可写(运行时可修改) |
| S_IRUGO | S_IWUSR | 0644 | 所有用户可读,root可写 |

S_IRUGO | S_IWUSR是最常用的组合,允许运行时通过sysfs修改参数值。

传参方式

# insmod传参
sudo insmod param_demo.ko count=5 name="mydev" enable=1 values=10,20,30

# modprobe传参(写。etc/modprobe.d/下或命令行)
sudo modprobe param_demo count=5 name="mydev"

# sysfs运行时修改(需要S_IWUSR权限。echo 10 | sudo tee /sys/module/param_demo/parameters/count

自定义参数回调

当参数被修改时需要执行额外逻辑,用module_param_cb注册回调,

// param_cb_demo.c - 参数回调示例
#include <linux/module.h>
#include <linux/init.h>
#include <linux/moduleparam.h>

static int threshold = 100;

/* 参数设置回调 */
static int threshold_set(const char *val, const struct kernel_param *kp)
{
    int ret;
    long new_val;

    ret = kstrtol(val, 10, &new_val);
    if (ret < 0)
        return ret;

    if (new_val < 0 || new_val > 1000) {
        pr_err("threshold: value %ld out of range [0, 1000]\n", new_val);
        return -EINVAL;
    }

    pr_info("threshold: changed from %d to %ld\n", *(int *)kp->arg, new_val);
    *(int *)kp->arg = (int)new_val;
    return 0;
}

/* 参数获取回调 */
static int threshold_get(char *buf, const struct kernel_param *kp)
{
    return scnprintf(buf, PAGE_SIZE, "%d\n", *(int *)kp->arg);
}

/* 定义kernel_param_ops */
static const struct kernel_param_ops threshold_ops = {
    .set = threshold_set,
    .get = threshold_get,
};

module_param_cb(threshold, &threshold_ops, &threshold, S_IRUGO | S_IWUSR);
MODULE_PARM_DESC(threshold, "阈值(0-1000,默认500)");

static int __init cb_init(void)
{
    pr_info("cb: threshold=%d\n", threshold);
    return 0;
}

static void __exit cb_exit(void)
{
    pr_info("cb: module unloaded, threshold=%d\n", threshold);
}

module_init(cb_init);
module_exit(cb_exit);

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("参数回调示例");

kernel_param_ops结构体在Linux 3.0引入,替代了旧的module_param_call宏。.set在写sysfs时触发,.get在读sysfs时触发。

五、符号导出

内核模块通过EXPORT_SYMBOL将函数和变量暴露给其他模块使用,这是模块间协作的基础。

EXPORT_SYMBOL vs EXPORT_SYMBOL_GPL

宏 可见性 说明
EXPORT_SYMBOL 所有模块可用 导出符号对任意许可证模块开放
EXPORT_SYMBOL_GPL 仅GPL模块可用 非GPL模块使用会报"Unknown symbol"错误

内核中大量核心符号(如kmalloc、printk)都是GPL-only的,这就是为什么MODULE_LICENSE("GPL")几乎是必选项。

模块间调用示例

模块A导出函数。

// module_a.c - 导出符号
#include <linux/module.h>
#include <linux/init.h>

static int add(int a, int b)
{
    return a + b;
}

/* 导出符号,其他模块可调用 */
EXPORT_SYMBOL(add);

static int __init mod_a_init(void)
{
    pr_info("mod_a: loaded, add() exported\n");
    return 0;
}

static void __exit mod_a_exit(void)
{
    pr_info("mod_a: unloaded\n");
}

module_init(mod_a_init);
module_exit(mod_a_exit);

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("符号导出模块A");

模块B调用导出函数。

// module_b.c - 使用导出符号
#include <linux/module.h>
#include <linux/init.h>

/* 声明外部符号 */
extern int add(int a, int b);

static int __init mod_b_init(void)
{
    int result = add(3, 5);
    pr_info("mod_b: add(3, 5) = %d\n", result);
    return 0;
}

static void __exit mod_b_exit(void)
{
    pr_info("mod_b: unloaded\n");
}

module_init(mod_b_init);
module_exit(mod_b_exit);

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("符号调用模块B");

加载顺序必须是先A后B,否则B找不到add符号。

sudo insmod module_a.ko
sudo insmod module_b.ko
dmesg | tail -5
# 输出: mod_b: add(3, 5) = 8

查看内核符号

# 查看所有内核导出符号cat /proc/kallsyms | grep add

# 查看模块导出的符号grep -E "EXPORT_SYMBOL" module_a.ko  # 不准确,应看Module.symvers

# 查看Module.symvers(编译后生成。cat Module.symvers

/proc/kallsyms列出内核和已加载模块的所有符号地址。未开启kptr_restrict时显示真实地址,否则显示全0。

GPL-only符号的限制

如果模块B的许可证不是GPL,

MODULE_LICENSE("Proprietary");  // 非GPL

那么调用EXPORT_SYMBOL_GPL导出的符号时,insmod会报错:

Unknown symbol in module

内核维护两个符号表:__ksymtab(普通导出)和__ksymtab_gpl(GPL-only导出),加载模块时根据许可证决定能访问哪些符号。

六、模块依赖

MODULE_SOFTDEP声明软依赖

MODULE_SOFTDEP("pre: module_a");    // 前置依赖:加载前先加载module_a
MODULE_SOFTDEP("post: module_c");   // 后置依赖:加载后再加载module_c

MODULE_SOFTDEP只是声明元信息,不会自动加载依赖模块。modprobe读取这些信息后可以按顺序加载。

request_module()动态加载

// req_mod_demo.c - 动态请求加载模块#include <linux/module.h>
#include <linux/init.h>

static int __init req_init(void)
{
    int ret;

    /* 请求内核加载指定模块 */
    ret = request_module("module_a");
    if (ret < 0) {
        pr_err("req: failed to load module_a, ret=%d\n", ret);
        return ret;
    }

    pr_info("req: module_a loaded on demand\n");
    return 0;
}

static void __exit req_exit(void)
{
    pr_info("req: module unloaded\n");
}

module_init(req_init);
module_exit(req_exit);

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("动态加载依赖模块示例);

request_module在内核空间发起用户空间的modprobe调用,要求modprobe配置正确且模块已安装到/lib/modules/$(uname -r)/目录下。

通知链实现模块间通信

通知链(Notifier Chain)是内核中模块间松耦合通信的标准机制,不依赖符号导出与

来自 linuxros.cn · linuxROS
// notifier_demo.c - 通知链示例#include <linux/module.h>
#include <linux/init.h>
#include <linux/notifier.h>

/* 自定义通知事件。*/
#define MY_EVENT_BASE 0x8000
#define MY_EVENT_DATA_READY  (MY_EVENT_BASE + 1)
#define MY_EVENT_STOP        (MY_EVENT_BASE + 2)

/* 定义通知链头 */
static BLOCKING_NOTIFIER_HEAD(my_notifier_list);

/* 注册/注销通知链的导出函数 */
int my_register_notifier(struct notifier_block *nb)
{
    return blocking_notifier_chain_register(&my_notifier_list, nb);
}
EXPORT_SYMBOL(my_register_notifier);

int my_unregister_notifier(struct notifier_block *nb)
{
    return blocking_notifier_chain_unregister(&my_notifier_list, nb);
}
EXPORT_SYMBOL(my_unregister_notifier);

/* 发送通知的导出函数*/
void my_notify_event(unsigned long event, void *data)
{
    blocking_notifier_call_chain(&my_notifier_list, event, data);
}
EXPORT_SYMBOL(my_notify_event);

static int __init notifier_init(void)
{
    pr_info("notifier: chain initialized\n");
    return 0;
}

static void __exit notifier_exit(void)
{
    pr_info("notifier: module unloaded\n");
}

module_init(notifier_init);
module_exit(notifier_exit);

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("通知链示例);

接收方模块注册回调:

// notifier_client.c - 通知链客户端
#include <linux/module.h>
#include <linux/init.h>
#include <linux/notifier.h>

extern int my_register_notifier(struct notifier_block *nb);
extern int my_unregister_notifier(struct notifier_block *nb);

#define MY_EVENT_BASE 0x8000
#define MY_EVENT_DATA_READY  (MY_EVENT_BASE + 1)

static int my_event_handler(struct notifier_block *nb,
                            unsigned long event, void *data)
{
    switch (event) {
    case MY_EVENT_DATA_READY:
        pr_info("client: received DATA_READY event, data=%p\n", data);
        break;
    default:
        break;
    }
    return NOTIFY_DONE;
}

static struct notifier_block my_nb = {
    .notifier_call = my_event_handler,
    .priority = 0,
};

static int __init client_init(void)
{
    int ret = my_register_notifier(&my_nb);
    if (ret)
        pr_err("client: register failed, ret=%d\n", ret);
    else
        pr_info("client: registered\n");
    return ret;
}

static void __exit client_exit(void)
{
    my_unregister_notifier(&my_nb);
    pr_info("client: unregistered\n");
}

module_init(client_init);
module_exit(client_exit);

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("通知链客户端示例");

通知链有三种类型。
| 类型 | 宏 | 上下文 | 特点 |
|:-----|:---|:-------|:-----|
| 阻塞通知链 | BLOCKING_NOTIFIER_HEAD | 进程上下文 | 可睡眠 |
| 原始通知链 | RAW_NOTIFIER_HEAD | 任意上下文 | 无锁保护,需调用者自行保护 |
| SRCU通知链 | SRCU_NOTIFIER_HEAD | 任意上下文 | 可睡眠,开销比阻塞链大 |

驱动开发中BLOCKING_NOTIFIER_HEAD最常用,

七、模块版本控制

MODULE_VERSION

MODULE_VERSION("1.0.0");

版本号字符串,modinfo可查看。内核不解析版本号的语义,纯信息用途。

内核版本条件编译

#include <linux/version.h>

#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 8, 0)
    /* 6.8及以上使用新API */
    ret = new_api_call();
#else
    /* 旧版使用兼容API */
    ret = legacy_api_call();
#endif

LINUX_VERSION_CODE是当前内核版本的数值编码,KERNEL_VERSION(major, minor, patch)生成对应数值,两者直接比较即可。跨内核版本发布驱动时必须用这种方式处理API差异,

Module.symvers与CRC校验

内核启用CONFIG_MODVERSIONS后,每个导出符号会计算CRC校验值,记录在Module.symvers文件中:

0xabc12345  add  module_a  EXPORT_SYMBOL_GPL

格式为:CRC值 符号。 模块。 导出类型。
加载模块时内核会比较符号的CRC值,不匹配则拒绝加载并报"Disagrees about version of symbol"错误。这是内核ABI兼容性检查机制。
开发外部模块时,必须使用目标内核的Module.symvers文件编译,否则CRC不匹配。Ubuntu 24.04的文件位于:

/lib/modules/6.8.x-xx-generic/build/Module.symvers

内核版本变化时的API适配

Linux内核没有稳定的内部ABI,每个大版本都可能变更API。实际开发中常见的适配场景。
- 函数签名变化:参数增减或类型改变
- 函数重命名或移除:如ioctl改为unlocked_ioctl
- 结构体字段调整:如file_operations新增成员
- 头文件路径变更
应对策略:用LINUX_VERSION_CODE条件编译,或针对特定内核版本维护不同代码分支。

八、模块运行时管理

引用计数

内核通过引用计数防止模块在使用中被卸载:

// 引用计数操作
if (!try_module_get(THIS_MODULE)) {
    pr_err("failed to get module reference\n");
    return -ENODEV;
}

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

module_put(THIS_MODULE);
函数 作用 返回回调
try_module_get(mod) 增加引用计数 成功返回0,模块正在卸载时返回0
module_put(mod) 减少引用计数 无返回值

try_module_get在引用前调用,失败说明模块正在卸载,不能再使用。module_put在使用完毕后调用。引用计数不为零时rmmod会失败。

fops.owner自动管理

字符设备驱动中,将file_operations.owner设为THIS_MODULE,内核会在open时自动增加引用计数,release时自动减少:

static const struct file_operations my_fops = {
    .owner   = THIS_MODULE,  // 内核自动管理引用计数
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
};

这是最推荐的引用计数管理方式,不需要手动调用try_module_get/module_put。

模块状态查看

#include <linux/module.h>

/* 查看模块状态*/
enum module_state {
    MODULE_STATE_LIVE,      // 正常运行
    MODULE_STATE_COMING,    // 正在加载
    MODULE_STATE_GOING,     // 正在卸载
    MODULE_STATE_UNFORMED,  // 尚未完成初始化};

通过THIS_MODULE->state可以获取当前模块状态。

强制卸载的风险

sudo rmmod -f module_name

-f标志强制卸载,不管引用计数是否为0。这会导致正在使用该模块的代码访问已释放内存,直接后果是内核崩溃(oops/panic)。只在调试阶段使用,生产环境绝对禁止。

九、初始化优先级

内核定义7级initcall,决定模块初始化的执行顺序:

// 各级initcall宏(定义在include/linux/init.h)
pure_initcall(fn)       // 0 - 仅对内建代码有效
core_initcall(fn)       // 1 - 核心子系统(中断控制器、时钟)
postcore_initcall(fn)   // 2 - 总线注册(PCI、USB总线)
arch_initcall(fn)       // 3 - 架构相关初始化
subsys_initcall(fn)     // 4 - 子系统初始化(网络、块设备)
fs_initcall(fn)         // 5 - 文件系统初始化
device_initcall(fn)     // 6 - 设备驱动(大多数驱动的默认级别)
late_initcall(fn)       // 7 - 延迟初始化(依赖前面所有子系统就绪)
优先级 宏 级数 适用场景
最高 pure_initcall 0 极早期,几乎不用
1 core_initcall 1 中断控制器、时钟框架、DMA引擎
2 postcore_initcall 2 总线类型注册(platform/PCI/USB)
3 arch_initcall 3 SoC特定初始化、引脚配置
4 subsys_initcall 4 网络子系统、输入子系统、注册表
5 fs_initcall 5 文件系统注册(ext4/tmpfs)
6 device_initcall 6 普通设备驱动,module_init默认等同此级
最低 late_initcall 7 依赖前面所有子系统就绪后才能初始化的驱动

对于可加载模块,module_init注册的函数在insmod时执行,initcall优先级只对内建代码有效。但理解这个顺序对调试初始化依赖问题很关键——如果驱动依赖某个子系统,而子系统初始化更晚,就会出问题。

十、常见问题

*Q1:insmod。Invalid module format"

内核版本不匹配。模块编译时使用的内核头文件版本与运行内核不一致。检查方法:

modinfo hello.ko | grep vermagic
uname -r

两者的版本号必须一致。解决:安装正确版本的linux-headers,重新编译模块。
Q2:modprobe找不到模块?

modprobe在/lib/modules/$(uname -r)/目录下搜索模块。如果模块不在该目录,需要先复制过去并更新依赖:

sudo cp hello.ko /lib/modules/$(uname -r)/extra/
sudo depmod -a
sudo modprobe hello

也可以在/etc/modprobe.d/下创建配置文件指定模块搜索路径。
Q3:模块卸载失败?

rmmod。Resource temporarily unavailable"。Device or resource busy",说明引用计数不为零。

# 查看引用计数
cat /proc/modules | grep hello
# 输出: hello 16384 1 - Live 0x...

# 第三列就是引用计数lsmod | grep hello

先关闭使用该模块的设备(如关闭打开的设备文件),再卸载。不要用rmmod -f强制卸载。

十一、总结

内核模块是Linux驱动开发的基本单元。从最简的Hello World到模块间通信,核心脉络是:模块加载卸载机制 。参数配置 。符号导出与依赖。运行时管理。掌握这些基础,后续写字符设备、Platform驱动、I2C/SPI驱动才有根基。
开发场景速查表
| 场景 | API/方法 |
|:-----|:---------|
| 加载时传递 | module_param / module_param_array |
| 运行时改参数 | module_param_cb + kernel_param_ops,权限设S_IWUSR |
| 模块间共享函数 | EXPORT_SYMBOL / EXPORT_SYMBOL_GPL |
| 声明模块依赖 | MODULE_SOFTDEP |
| 动态加载依赖 | request_module() |
| 模块间松耦合通信 | blocking_notifier_chain |
| 跨内核版本兼容 | LINUX_VERSION_CODE + KERNEL_VERSION() |
| 防止使用中卸载 | try_module_get / module_put,或fops.owner = THIS_MODULE |
| 查看内核符号 | cat /proc/kallsyms |
| 查看模块信息 | modinfo / lsmod / /sys/module/ |

本文首发于linuxros.cn,转载请注明出处。

版权声明

作者linuxROS
协议本作品采用 CC BY-NC-SA 4.0 许可协议:署名-非商业性使用-相同方式共享
关注欢迎关注微信公众号 linuxROS,获取更多机器人 / 嵌入式 / Linux 干货
返回首页