4. 第一个字符设备驱动:Hello World驱动编写、模块初始化与退出函数、module_init/module_exit宏、编译与加载测试

本章是字符设备驱动开发的起点。我们将从零编写一个最简单的“Hello World”内核模块,通过它理解驱动模块的生命周期:加载 → 初始化 → 运行 → 卸载 → 退出。你将掌握 module_init 与 module_exit 宏的底层机制,并完成编译、加载、验证与卸载的完整闭环。

4.1 内核模块的基本结构

一个字符设备驱动本质上是一个可加载内核模块(Loadable Kernel Module, LKM)。它不依赖完整的应用程序框架,而是通过内核提供的 API 与内核空间交互。每个模块必须包含两个核心函数:

函数角色 函数名(惯例) 触发时机 典型职责
初始化入口 hello_init insmod 加载模块时 注册设备号、初始化数据结构、创建设备文件
退出清理 hello_exit rmmod 卸载模块时 注销设备号、释放内存、删除设备文件
关键认知: 内核模块不是进程,没有 main() 函数。它的执行由内核事件驱动——加载时调用初始化函数,卸载时调用退出函数。这种“钩子”机制是驱动开发的基础。

4.2 编写 Hello World 驱动代码

下面是一个完整的字符设备驱动骨架,它不操作硬件,只在加载和卸载时打印信息,并创建一个简单的设备节点。

#include <linux/module.h>      // 模块相关宏和函数
#include <linux/kernel.h>      // printk, KERN_INFO
#include <linux/fs.h>          // 文件操作结构体
#include <linux/device.h>      // 设备类
#include <linux/cdev.h>        // 字符设备结构体

#define DEVICE_NAME "hello_dev"
#define CLASS_NAME  "hello_class"

static int major_num;                 // 主设备号
static struct class *hello_class = NULL;
static struct device *hello_device = NULL;
static struct cdev hello_cdev;

// 设备打开函数(空实现)
static int hello_open(struct inode *inodep, struct file *filep) {
    printk(KERN_INFO "hello_dev: device opened\n");
    return 0;
}

// 设备释放函数(空实现)
static int hello_release(struct inode *inodep, struct file *filep) {
    printk(KERN_INFO "hello_dev: device closed\n");
    return 0;
}

// 文件操作结构体:关联用户空间操作与驱动函数
static struct file_operations hello_fops = {
    .owner   = THIS_MODULE,
    .open    = hello_open,
    .release = hello_release,
};

// 模块初始化函数
static int __init hello_init(void) {
    dev_t dev_num;

    printk(KERN_INFO "hello_dev: Initializing...\n");

    // 1. 动态分配主设备号
    if (alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME) < 0) {
        printk(KERN_ALERT "hello_dev: Failed to allocate major number\n");
        return -1;
    }
    major_num = MAJOR(dev_num);
    printk(KERN_INFO "hello_dev: Major number = %d\n", major_num);

    // 2. 初始化 cdev 并添加到内核
    cdev_init(&hello_cdev, &hello_fops);
    hello_cdev.owner = THIS_MODULE;
    if (cdev_add(&hello_cdev, dev_num, 1) < 0) {
        printk(KERN_ALERT "hello_dev: Failed to add cdev\n");
        unregister_chrdev_region(dev_num, 1);
        return -1;
    }

    // 3. 创建设备类(在 /sys/class 下可见)
    hello_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(hello_class)) {
        printk(KERN_ALERT "hello_dev: Failed to create class\n");
        cdev_del(&hello_cdev);
        unregister_chrdev_region(dev_num, 1);
        return PTR_ERR(hello_class);
    }

    // 4. 创建设备节点(自动在 /dev 下生成)
    hello_device = device_create(hello_class, NULL, dev_num, NULL, DEVICE_NAME);
    if (IS_ERR(hello_device)) {
        printk(KERN_ALERT "hello_dev: Failed to create device\n");
        class_destroy(hello_class);
        cdev_del(&hello_cdev);
        unregister_chrdev_region(dev_num, 1);
        return PTR_ERR(hello_device);
    }

    printk(KERN_INFO "hello_dev: Hello World! Driver loaded successfully.\n");
    return 0;
}

// 模块退出函数
static void __exit hello_exit(void) {
    dev_t dev_num = MKDEV(major_num, 0);

    device_destroy(hello_class, dev_num);   // 移除设备节点
    class_destroy(hello_class);             // 移除设备类
    cdev_del(&hello_cdev);                  // 从内核删除 cdev
    unregister_chrdev_region(dev_num, 1);   // 释放设备号

    printk(KERN_INFO "hello_dev: Goodbye! Driver unloaded.\n");
}

// 注册模块入口/出口
module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple Hello World character device driver");
MODULE_VERSION("1.0");
编码规范提示: 使用 __init 和 __exit 标记初始化/退出函数,内核会在模块加载后释放 __init 段占用的内存。所有函数前都加上 static 避免符号污染内核命名空间。

4.3 module_init / module_exit 宏的底层机制

这两个宏是驱动与内核链接的“胶水”。它们不是简单的函数声明,而是通过编译器属性将函数指针放入特定的 ELF 段中。

module_init / module_exit 宏展开与链接流程 源码中调用 module_init(hello_init) 预处理展开为 __initcall(hello_init) 放入 ELF 段 .initcall6.init insmod 加载 → 内核遍历 .initcall 段 → 按优先级调用函数指针 hello_init() 被执行,驱动开始工作 ⚠ 关键理解 module_exit 同理,函数指针被放入 .exitcall.exit 段,rmmod 时触发

宏展开后的实际效果:

  • module_init(hello_init) → 将 hello_init 的函数指针放入 .initcall6.init 段(优先级 6 是普通驱动级别)。
  • module_exit(hello_exit) → 将 hello_exit 的函数指针放入 .exitcall.exit 段。
  • 内核在 insmod 时扫描 .initcall 段,按优先级依次调用;rmmod 时调用 .exitcall 段中的函数。

4.4 Makefile 编写与编译

内核模块的编译必须依赖内核源码树(或内核头文件包)。标准 Makefile 模板如下:

# Makefile for hello_dev driver

# 内核源码路径(根据实际环境修改)
KDIR := /lib/modules/$(shell uname -r)/build

# 当前目录
PWD := $(shell pwd)

# 目标模块名称
obj-m := hello_dev.o

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

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

install:
    sudo insmod hello_dev.ko

remove:
    sudo rmmod hello_dev

info:
    lsmod | grep hello_dev
    dmesg | tail -10
常见编译错误: 如果提示 missing MODULE_LICENSE(),请确保代码中包含 MODULE_LICENSE("GPL")。非 GPL 兼容许可证会导致内核拒绝加载某些符号。

编译步骤:

  1. 将 hello_dev.c 和 Makefile 放在同一目录。
  2. 执行 make,生成 hello_dev.ko(内核对象文件)。
  3. 使用 file hello_dev.ko 确认文件类型为 ELF 64-bit LSB relocatable。

4.5 加载、测试与卸载

编译成功后,按以下步骤验证驱动:

步骤 命令 预期结果
1. 加载模块 sudo insmod hello_dev.ko 无输出(成功),或 dmesg 显示初始化信息
2. 检查设备节点 ls -l /dev/hello_dev 显示字符设备文件,主设备号与 dmesg 一致
3. 查看模块列表 lsmod | grep hello_dev 显示模块名称、大小、被引用次数
4. 查看内核日志 dmesg | tail -15 显示 "hello_dev: Initializing..." 和 "Hello World!"
5. 测试设备打开 cat /dev/hello_dev dmesg 显示 "hello_dev: device opened" 和 "closed"
6. 卸载模块 sudo rmmod hello_dev dmesg 显示 "Goodbye! Driver unloaded."
调试技巧: 使用 dmesg -w 实时监控内核日志,加载/卸载时立即看到输出。如果设备节点未自动生成,检查 /sys/class/hello_class/ 是否存在。

4.6 驱动加载与卸载的完整生命周期

下面的 SVG 图展示了从 insmod 到 rmmod 的完整调用链:

Hello World 驱动加载/卸载生命周期 insmod 命令 内核加载模块 调用 hello_init() 初始化内部步骤 1. alloc_chrdev_region 2. cdev_init + cdev_add 3. class_create 4. device_create rmmod 命令 内核卸载模块 调用 hello_exit() 退出清理步骤 1. device_destroy 2. class_destroy 3. cdev_del 4. unregister_chrdev_region ⚠ 资源释放顺序必须与申请顺序相反 否则可能导致内核崩溃或设备节点残留

4.7 常见问题与避坑指南

  • 模块加载失败:insmod: ERROR: could not insert module — 使用 dmesg 查看具体错误,通常是设备号冲突或内存不足。
  • 设备节点未出现: 确认 udev 服务是否运行,或手动创建 mknod /dev/hello_dev c <major> 0。
  • 卸载时内核崩溃: 检查退出函数中是否释放了所有申请的资源,特别是 cdev_del 和 unregister_chrdev_region 的顺序。
  • 权限问题: 加载/卸载模块需要 root 权限,使用 sudo 执行。
本章小结: 你已成功编写并运行了第一个字符设备驱动。掌握了 module_init/module_exit 的宏机制、设备号分配、cdev 注册、设备类创建以及完整的加载/卸载流程。这是后续实现 ioctl、read/write 等操作的基础骨架。