Linux 驱动开发入门:内核模块编程基础

1. 系统调用概述

系统调用是操作系统提供给用户空间程序访问内核服务的唯一合法入口。用户程序通过系统调用请求内核完成文件读写、进程管理、内存分配、网络通信等底层操作,而无需直接操作硬件或访问内核数据结构。

系统调用本质上是一组编号的函数接口,每个系统调用都有唯一的编号。在 Linux 中,系统调用通过软中断(x86 架构下为 int 0x80 或 syscall 指令)从用户态陷入内核态,由内核根据系统调用号分发到对应的处理函数。

2. 为什么需要系统调用

系统调用的存在主要基于以下三个原因:

  • 安全隔离:用户程序不能直接访问硬件和内核内存,必须通过内核验证后才能执行特权操作,防止恶意程序破坏系统。
  • 稳定抽象:系统调用为上层应用提供统一的接口,屏蔽了底层硬件差异,使程序具备良好的可移植性。
  • 资源管理:内核通过系统调用统一管理 CPU、内存、设备等资源,保证多进程并发时的公平性和安全性。

3. 系统调用实现

系统调用的实现过程可以概括为以下几个步骤:

  1. 用户程序调用库函数(如 glibc 中的 read()),库函数将参数放入寄存器并触发陷入指令。
  2. CPU 从用户态切换到内核态,跳转到内核的入口处理程序。
  3. 内核根据系统调用号在系统调用表中查找对应的处理函数。
  4. 处理函数执行完毕后,将结果返回给用户程序,CPU 切回用户态。

在 Linux 内核源码中,系统调用表定义在 arch/x86/entry/syscalls/syscall_64.tbl 文件中,每个条目包含系统调用号、名称和对应的内核实现函数。

4. 内核模块

内核模块(Kernel Module)是可以在运行时动态加载到内核中的代码片段,用于扩展内核功能而无需重新编译整个内核。常见的模块包括设备驱动程序、文件系统、网络协议等。

内核模块具有以下特点:

  • 动态加载 :模块可以在系统运行期间通过 insmod 或 modprobe 命令加载,也可以通过 rmmod 卸载,无需重启系统。
  • 内核态运行:模块运行在内核空间,拥有最高特权级,可以直接访问硬件和内核数据结构。
  • 独立编译 :模块可以独立于内核源码树编译,生成 .ko 文件。
  • 按需加载:只有需要时才加载对应模块,节省内存资源。

5. 模块如何加载进内核

下图展示了从用户执行 insmod 命令到内核完成模块加载的完整流程:

flowchart TD A[用户执行 insmod hello.ko] --> B[insmod 工具调用 init_module 系统调用] B --> C[内核将模块文件内容复制到内核内存] C --> D[内核进行模块依赖检查] D --> E{依赖符号是否全部解析} E -- 否 --> F[加载失败并返回错误] E -- 是 --> G[内核执行模块初始化函数] G --> H[模块注册到内核模块链表] H --> I[加载完成,可通过 lsmod 查看]

整个加载过程由用户态工具 insmod 发起,通过 init_module() 系统调用进入内核态。内核依次完成模块文件复制、依赖检查、初始化函数执行和模块链表注册等步骤,最终使模块在内核中生效。

内核模块的加载过程涉及用户态工具和内核态的协同工作,具体流程如下:

  1. 用户执行 insmod hello.ko 命令,该命令由 insmod 工具调用 init_module() 系统调用。
  2. 内核接收到加载请求后,将模块文件的内容复制到内核内存中。
  3. 内核进行模块依赖检查,确认模块引用的符号(如函数、变量)都已解析。
  4. 内核执行模块的初始化函数(由 module_init() 宏指定的函数)。
  5. 初始化成功后,模块被注册到内核的模块链表中,并可以通过 lsmod 命令查看。

除了 insmod,Linux 还提供了更强大的模块加载工具 modprobe。两者的核心区别在于依赖处理:insmod 只负责加载指定的单个模块文件,不会自动解析该模块所依赖的其他模块;而 modprobe 会先检查模块的依赖关系,自动加载所有必需的依赖模块,再加载目标模块。

例如,某个驱动模块依赖 usbcore 和 usb_common,使用 insmod 加载时若这些依赖尚未加载,会报错失败;而使用 modprobe 加载时,它会根据模块间的符号依赖自动按顺序加载这些依赖模块,确保目标模块可以正常初始化。

此外,modprobe 还支持按模块名(而非完整路径)加载模块,它会自动在默认模块目录(如 /lib/modules/$(uname -r)/)下查找对应的 .ko 文件,因此无需手动指定文件路径。

modprobe 的工作原理

modprobe 的工作流程可以概括为以下几个步骤:

  1. 解析模块名或模块别名,在模块目录中查找对应的 .ko 文件。
  2. 读取模块的依赖信息(由 depmod 生成的 modules.dep 文件),确定目标模块依赖的其他模块。
  3. 按依赖顺序依次加载所有依赖模块,最后加载目标模块。
  4. 加载过程中同样调用 init_module() 系统调用,执行各模块的初始化函数。

其中 modules.dep 文件由 depmod 工具生成,它记录了内核模块之间的依赖关系。系统启动或安装新模块后,通常需要运行 depmod -a 重新生成该文件,否则 modprobe 可能无法正确解析依赖。

modprobe 的常用命令

加载模块(自动处理依赖):

bash 复制代码
modprobe hello

加载模块并传递参数:

bash 复制代码
modprobe hello count=5 name="linux"

卸载模块(同时卸载不再被使用的依赖模块):

bash 复制代码
modprobe -r hello

查看模块信息:

bash 复制代码
modinfo hello

查看当前已加载的模块:

bash 复制代码
lsmod

模块别名与自动加载

modprobe 还支持模块别名(alias)机制。模块可以通过 MODULE_ALIAS() 宏声明别名,例如网卡驱动可以声明 MODULE_ALIAS("pci:v00008086d0000100Esv*sd*bc*sc*i*")。当系统检测到对应硬件时,内核会通过 modprobe 根据别名自动加载匹配的驱动模块,实现设备的即插即用。

这一机制是 Linux 设备自动识别和驱动自动加载的基础,也是 modprobe 相比 insmod 更适用于日常管理的重要原因。

卸载模块

卸载模块时,用户执行 rmmod hello,内核调用 delete_module() 系统调用,先执行模块的清理函数(由 module_exit() 宏指定),然后释放模块占用的内存。若使用 modprobe -r hello 卸载,则会同时卸载不再被其他模块引用的依赖模块。

6. 内核模块编写流程

编写一个内核模块通常遵循以下流程:

  1. 包含头文件 :引入 linux/module.h、linux/kernel.h 和 linux/init.h 等必要头文件。
  2. 定义模块信息 :使用 MODULE_LICENSE()、MODULE_AUTHOR()、MODULE_DESCRIPTION() 等宏声明模块元信息。
  3. 编写初始化函数 :使用 module_init() 宏注册入口函数,该函数在模块加载时执行。
  4. 编写清理函数 :使用 module_exit() 宏注册退出函数,该函数在模块卸载时执行。
  5. 编写 Makefile:使用内核提供的 kbuild 系统编译模块。

6.1 模块入口函数

模块入口函数是模块加载到内核时被自动调用的函数,负责完成模块的初始化工作,如注册设备、分配资源、初始化数据结构等。入口函数通过 module_init() 宏注册。

module_init() 宏的定义格式如下:

c 复制代码
#define module_init(x) __initcall(x);

入口函数的定义格式和规范如下:

c 复制代码
static int __init 函数名(void)
{
    /* 初始化代码 */
    return 0;  /* 返回 0 表示初始化成功 */
}

入口函数需要遵循以下规则:

  • 返回类型必须为 int :返回 0 表示初始化成功,模块可以正常加载;返回负数(如 -ENOMEM、-EIO)表示初始化失败,内核会中止模块加载并回滚已分配的资源。
  • 参数必须为空 :入口函数不接受任何参数,即参数列表为 void。
  • 使用 __init 标记:该标记告诉内核,此函数仅在初始化阶段使用,初始化完成后其占用的内存可以被释放回收,以节省内核内存空间。
  • 函数名可以自定义 :如 hello_init、my_module_init 等,只要通过 module_init() 宏注册即可。
  • 必须通过 module_init() 注册 :只有被 module_init() 宏注册的函数才会在模块加载时被内核调用。

入口函数注册示例:

c 复制代码
static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, kernel module!\n");
    return 0;
}

module_init(hello_init);

6.2 模块退出函数

模块退出函数是模块从内核卸载时被自动调用的函数,负责释放模块占用的资源,如注销设备、释放内存、删除文件等。退出函数通过 module_exit() 宏注册。

module_exit() 宏的定义格式如下:

c 复制代码
#define module_exit(x) __exitcall(x);

退出函数的定义格式和规范如下:

c 复制代码
static void __exit 函数名(void)
{
    /* 清理代码 */
}

退出函数需要遵循以下规则:

  • 返回类型必须为 void:退出函数不需要返回值。
  • 参数必须为空:与入口函数一样,退出函数也不接受任何参数。
  • 使用 __exit 标记:该标记告诉内核,此函数仅在模块卸载时使用。如果模块被编译进内核(而非作为可加载模块),该标记会使函数被优化掉,因此退出函数不能在被编译进内核时调用。
  • 函数名可以自定义 :如 hello_exit、my_module_exit 等,只要通过 module_exit() 宏注册即可。
  • 必须通过 module_exit() 注册 :只有被 module_exit() 宏注册的函数才会在模块卸载时被内核调用。

退出函数注册示例:

c 复制代码
static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, kernel module!\n");
}

module_exit(hello_exit);

6.3 完整示例

下面是一个包含入口函数和退出函数的完整内核模块示例:

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

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, kernel module!\n");
    return 0;
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, kernel module!\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple hello world module");

加载和卸载该模块时,可以通过 dmesg 命令查看内核日志中打印的 Hello, kernel module! 和 Goodbye, kernel module! 信息,验证入口函数和退出函数是否被正确调用。

7. 内核模块编译

内核模块的编译依赖内核源码树中的 kbuild 系统。需要先准备一个 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

编译过程说明:

  • obj-m 告诉 kbuild 系统将 hello.o 编译为可加载模块。
  • KDIR 指向当前内核版本的构建目录,通常由内核头文件包提供。
  • make -C $(KDIR) M=$(PWD) modules 进入内核构建目录,并指定当前目录为模块源码目录进行编译。
  • 编译成功后生成 hello.ko 文件,这就是可加载的内核模块。

编译前需要确保系统已安装内核开发包(如 linux-headers-$(uname -r)),否则 KDIR 目录不存在,编译会失败。

8. 内核模块许可声明

内核模块的许可证声明非常重要,它决定了模块是否可以链接内核中标记为 GPL-only 的符号。常用的许可证宏如下:

  • MODULE_LICENSE("GPL"):声明模块使用 GPL 许可证,可以访问所有内核符号。
  • MODULE_LICENSE("GPL v2"):声明使用 GPL v2 许可证。
  • MODULE_LICENSE("Dual BSD/GPL"):声明双重许可证。
  • MODULE_LICENSE("Proprietary"):声明为专有许可证,无法使用 GPL-only 符号。

模块文档宏用于描述模块的基本信息,加载后可通过 modinfo 命令查看:

c 复制代码
MODULE_AUTHOR("Author Name");
MODULE_DESCRIPTION("Module description");
MODULE_VERSION("1.0");
MODULE_ALIAS("alias-name");

这些信息不仅方便维护,也有助于用户了解模块的功能和来源。

9. 内核模块参数

内核模块支持在加载时传递参数,通过 module_param() 宏声明。参数可以是整型、字符串、布尔值等类型。

声明参数的示例:

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

static int count = 1;
static char *name = "default";

module_param(count, int, 0644);
module_param(name, charp, 0644);
MODULE_PARM_DESC(count, "Number of iterations");
MODULE_PARM_DESC(name, "User name");

加载模块时传递参数:

bash 复制代码
insmod hello.ko count=5 name="linux"

参数说明:

  • module_param(name, type, perm):第一个参数是变量名,第二个是类型(int、charp、bool 等),第三个是 sysfs 权限位。
  • 权限位为 0 表示参数不暴露在 sysfs 中;0644 表示可在 /sys/module/hello/parameters/ 下查看和修改。
  • MODULE_PARM_DESC() 用于描述参数含义,可通过 modinfo 查看。

除了标量类型,内核模块参数还可以声明为数组,用于接收多个同类型的值。数组参数通过 module_param_array() 宏声明,需要额外指定数组长度变量。

声明数组参数的示例:

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

static int arr[10];
static int arr_count;

module_param_array(arr, int, &arr_count, 0644);
MODULE_PARM_DESC(arr, "An array of integers");

加载模块时传递数组参数,多个值之间用逗号分隔:

bash 复制代码
insmod hello.ko arr=1,2,3,4,5

数组参数说明:

  • module_param_array(name, type, nump, perm):第一个参数是数组名,第二个是元素类型(int、charp 等),第三个是指向数组长度变量的指针,第四个是 sysfs 权限位。
  • 加载时传入的值的个数会写入 arr_count 变量,模块内可通过该变量获知实际传入的元素数量。
  • 数组参数同样支持 MODULE_PARM_DESC() 描述含义,可通过 modinfo 查看。
  • 如果传入的值的个数超过数组容量,内核会拒绝加载并报错。

除了标量类型和数组,内核模块参数还可以声明为字符数组(char 数组),用于接收字符串类型的参数。字符数组参数同样通过 module_param_array() 宏声明,但元素类型指定为 char,并且需要额外指定数组长度变量。

声明字符数组参数的示例:

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

static char names[4][32];
static int names_count;

module_param_array(names, char, &names_count, 0644);
MODULE_PARM_DESC(names, "An array of strings");

加载模块时传递字符数组参数,多个字符串之间用逗号分隔:

bash 复制代码
insmod hello.ko names="linux,android,ubuntu"

字符数组参数说明:

  • module_param_array(name, char, nump, perm):第一个参数是二维字符数组名,第二个是元素类型 char,第三个是指向数组长度变量的指针,第四个是 sysfs 权限位。
  • 字符数组参数要求声明为二维数组,如 char names[4][32],其中第一维表示最多可接收的字符串个数,第二维表示每个字符串的最大长度。
  • 加载时传入的字符串个数会写入 names_count 变量,模块内可通过该变量获知实际传入的字符串数量。
  • 如果传入的字符串个数超过第一维容量,或某个字符串长度超过第二维容量,内核会拒绝加载并报错。
  • 字符数组参数同样支持 MODULE_PARM_DESC() 描述含义,可通过 modinfo 查看。

除了上述参数声明方式,内核还提供了 module_param_string() 宏,用于声明一个固定长度的字符数组参数,专门接收字符串类型的值。与 module_param_array() 声明二维字符数组不同,module_param_string() 直接绑定一个一维字符数组,适合接收单个字符串参数。

module_param_string() 宏的定义格式如下:

c 复制代码
module_param_string(name, string, len, perm);

声明字符串参数的示例:

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

static char my_string[64];

module_param_string(my_string, my_string, sizeof(my_string), 0644);
MODULE_PARM_DESC(my_string, "A string parameter");

加载模块时传递字符串参数:

bash 复制代码
insmod hello.ko my_string="hello world"

module_param_string() 参数说明:

  • module_param_string(name, string, len, perm):第一个参数是模块参数名(用于命令行和 sysfs),第二个是实际存储字符串的字符数组变量名,第三个是数组长度(通常传 sizeof(string)),第四个是 sysfs 权限位。
  • 该宏要求字符数组必须预先声明为固定大小,如 char my_string[64],内核会将传入的字符串拷贝到该数组中,并自动在末尾追加 \0 结束符。
  • 如果传入的字符串长度超过数组容量,内核会截断字符串并自动添加结束符,不会导致缓冲区溢出。
  • 与 module_param(name, charp, perm) 不同,charp 类型只是保存指向字符串的指针,而 module_param_string() 会将字符串实际拷贝到模块自己的缓冲区中,更安全可靠。
  • module_param_string() 同样支持 MODULE_PARM_DESC() 描述含义,可通过 modinfo 查看。

10. 内核模块和应用程序的区别

内核模块与普通应用程序在运行环境、编程模型和资源访问等方面存在显著差异,主要区别如下:

对比项 内核模块 应用程序
运行空间 内核空间,特权级最高 用户空间,特权级受限
入口函数 module_init() 指定的初始化函数 main() 函数
内存访问 可直接访问内核内存和硬件 只能访问用户空间内存,通过系统调用访问内核
库函数 只能使用内核导出的函数(如 printk) 可使用 glibc 等用户态库
浮点运算 默认不支持,需特殊处理 支持
崩溃影响 模块崩溃可能导致整个系统崩溃 程序崩溃通常只影响自身进程
调试方式 通过 printk、dmesg 查看日志 可使用 gdb 等调试器

理解这些区别有助于开发者正确选择编程模型:需要直接操作硬件或扩展内核功能时使用内核模块,普通业务逻辑则应放在用户空间实现。

11. 总结

本文围绕 Linux 内核模块编程的基础知识展开,从系统调用讲起,逐步深入到内核模块的编写、加载、编译与参数传递。核心要点可以概括为以下几点:

  • 系统调用是用户空间访问内核的唯一合法入口,通过软中断陷入内核态,由系统调用表分发到对应处理函数,实现了安全隔离、稳定抽象和资源管理。
  • 内核模块是动态扩展内核功能的代码片段 ,运行在内核态、可独立编译为 .ko 文件,并通过 insmod 或 modprobe 按需加载,无需重启系统。
  • 模块编写遵循固定流程 :包含必要头文件、声明模块元信息、编写并注册入口函数(module_init())和退出函数(module_exit()),再通过 kbuild 系统编译生成 .ko 文件。
  • 模块参数支持多种类型 :包括标量(module_param())、数组(module_param_array())以及字符串(module_param_string()),加载时通过命令行传递,并可通过 modinfo 查看描述。
  • 内核模块与应用程序差异显著:模块运行在内核空间、特权级最高、崩溃影响整个系统,而应用程序运行在用户空间、通过系统调用访问内核,开发时需根据场景正确选择编程模型。

掌握这些基础知识后,读者可以进一步学习设备驱动开发、内核同步机制、内存管理等进阶内容,逐步深入 Linux 内核的世界。

相关推荐
智能直播1 小时前
RK3528 V4L2 框架 UVC 接口自动获取 H.264 / MJPEG / YUYV 格式:从内核采集到硬解分发
java·网络·实时音视频·视频编解码·h.264
别动我齐刘海1 小时前
Linux 系统调试学习2
linux·运维·c++·人工智能·学习·机器学习·机器人
龙虾电话2 小时前
语音机器人打断不进去:VAD 双阈值迟滞里一个共用的 bool,和它引发的连锁误判
linux
honsor2 小时前
机房IP化改造首选:以太网温湿度传感器,PoE供电+Modbus TCP一网搞定
linux·运维·服务器
睿智の男孩2 小时前
MIG IP核对DDR3的输出时序分析记录
网络·网络协议·tcp/ip
艾芯微科技2 小时前
高充放电流锂电充放 SoC:ETA9885E10,便携储能、小移动电源 BOM 选型笔记
网络·单片机·嵌入式硬件·集成测试·51单片机
2401_868534782 小时前
数据交换方式
服务器·网络
艾芯微科技2 小时前
钰泰 ETA6973PQ3Y|QFN3×3 2A 同步降压 Buck,I2C 可配置多档输出,动态调压 PMIC
网络·单片机·嵌入式硬件·fpga开发·集成测试·51单片机