3. 第一个内核模块

本章是嵌入式 Linux 驱动开发的第一个实战环节。你将亲手编写一个最简单的内核模块——Hello World,并掌握模块的编译、加载、卸载以及内核日志输出的核心概念。这是后续所有字符设备驱动的基础骨架。

3.1 内核模块的基本结构

一个 Linux 内核模块本质上是一段可以在运行时动态加载到内核的代码。它不像用户态程序有 main() 函数,而是通过两个固定的入口函数与内核交互:

  • 模块初始化入口:模块被加载时执行,通常用于注册设备、分配资源。
  • 模块卸载出口:模块被卸载时执行,用于释放资源、注销设备。

下面是最简内核模块的代码骨架:

#include <linux/module.h>    // 包含模块相关的宏和函数
#include <linux/kernel.h>    // 包含 printk 等内核函数
#include <linux/init.h>      // 包含 __init 和 __exit 宏

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, World! Module loaded.\n");
    return 0;   // 返回 0 表示加载成功
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, World! Module unloaded.\n");
}

module_init(hello_init);   // 注册初始化函数
module_exit(hello_exit);   // 注册退出函数

MODULE_LICENSE("GPL");              // 许可证声明,必须
MODULE_AUTHOR("Your Name");         // 作者信息
MODULE_DESCRIPTION("A simple Hello World module");  // 模块描述
关键点:__init 和 __exit 是内核宏,用于标记函数在初始化/退出后可以释放内存。所有内核模块必须声明 MODULE_LICENSE,否则内核会认为模块是“污染”的。

3.2 Makefile 编写:内核模块的编译系统

内核模块不能使用普通的 gcc 编译,必须借助内核的 Kbuild 构建系统。Makefile 的核心作用是告诉内核源码树如何编译外部模块。

标准的外部模块 Makefile 模板如下:

# 指定内核源码树的路径,根据实际环境修改
KERNELDIR ?= /lib/modules/$(shell uname -r)/build

# 当前模块所在目录
PWD := $(shell pwd)

# 目标模块名称(最终生成 .ko 文件)
obj-m := hello.o

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

clean:
    $(MAKE) -C $(KERNELDIR) M=$(PWD) clean
Makefile 变量/目标 含义
KERNELDIR 指向已编译的内核源码树或内核头文件目录,/lib/modules/$(uname -r)/build 是标准符号链接
obj-m 指定要编译成模块的目标文件列表,hello.o 对应 hello.c
-C $(KERNELDIR) 切换到内核源码目录读取其顶层 Makefile
M=$(PWD) 告诉 Kbuild 返回当前目录编译外部模块
modules 内核 Makefile 的目标,用于编译模块
常见错误:如果 KERNELDIR 指向的内核源码没有预先编译过(缺少 Module.symvers 等文件),编译会失败。在开发板上,通常需要先编译一次完整内核或至少编译内核模块依赖。

3.3 模块加载与卸载操作

编译成功后,会生成 hello.ko 文件。使用以下命令操作模块:

  • 加载模块:sudo insmod hello.ko
  • 查看已加载模块:lsmod | grep hello
  • 卸载模块:sudo rmmod hello
  • 查看模块信息:modinfo hello.ko

加载和卸载时,内核会自动调用你在代码中注册的 hello_init 和 hello_exit 函数。

操作流程:
1. 编写 hello.c 和 Makefile
2. 执行 make 编译生成 hello.ko
3. 执行 sudo insmod hello.ko 加载模块
4. 执行 dmesg | tail 查看内核日志输出
5. 执行 sudo rmmod hello 卸载模块
6. 再次执行 dmesg | tail 查看退出日志

3.4 printk 日志级别详解

printk 是内核空间的“printf”,但它比用户态 printf 多了一个日志级别(loglevel)。日志级别决定了消息的严重程度以及是否会被立即输出到控制台。

常用的日志级别宏定义在 <linux/kern_levels.h> 中:

宏 数值 含义 典型场景
KERN_EMERG 0 紧急事件,系统不可用 内核崩溃前
KERN_ALERT 1 必须立即处理 严重硬件故障
KERN_CRIT 2 临界条件 驱动初始化失败
KERN_ERR 3 错误条件 设备操作失败
KERN_WARNING 4 警告条件 不推荐的使用方式
KERN_NOTICE 5 正常但重要的消息 模块加载/卸载通知
KERN_INFO 6 信息性消息 驱动版本信息
KERN_DEBUG 7 调试消息 开发阶段的详细日志

使用方式:printk(KERN_INFO "Hello, World!\n"); —— 注意日志级别宏与格式化字符串之间没有逗号,预处理器会将它们拼接在一起。

控制台输出控制:内核通过 /proc/sys/kernel/printk 文件控制哪些级别的消息会显示在控制台。该文件有 4 个数值:控制台日志级别 默认消息级别 最低控制台级别 默认控制台级别。只有日志级别数值小于控制台日志级别的消息才会被打印到控制台。

3.5 完整流程与模块生命周期 SVG 图

下图展示了从编写代码到模块卸载的完整生命周期,以及 printk 日志在其中的作用位置:

Hello World 模块生命周期与 printk 日志流程 Hello World 模块生命周期与 printk 日志流程 1. 编写 hello.c 2. 编写 Makefile 3. make 编译 4. insmod hello.ko 5. 调用 hello_init() 6. printk(KERN_INFO ...) 7. dmesg 查看日志 8. rmmod hello 9. 调用 hello_exit() 10. printk 退出日志 printk 日志写入内核 环形缓冲区,dmesg 读取

3.6 本章小结

通过本章的学习,你已经掌握了:

  • 内核模块的固定代码结构:module_init / module_exit 入口。
  • 使用 Kbuild 系统编写 Makefile 编译外部模块。
  • insmod、rmmod、lsmod、dmesg 等模块管理命令。
  • printk 的 8 个日志级别及其在调试中的作用。