3. 第一个字符设备驱动:模块加载与卸载、注册字符设备、实现 open/release 操作

本章是字符设备驱动开发的起点。我们将从零构建一个完整的虚拟字符设备驱动,涵盖内核模块的加载/卸载框架、字符设备号的申请与注册,以及最基本的 open 和 release 操作实现。通过本章学习,你将掌握字符设备驱动的标准骨架,并能在 ARM 目标板上验证驱动的基本生命周期。

学习目标:
  • 理解内核模块的 module_init / module_exit 机制
  • 掌握动态/静态分配设备号的方法
  • 学会使用 cdev 结构体注册字符设备
  • 实现 open 和 release 回调函数
  • 在开发板上完成驱动加载、验证与卸载

3.1 内核模块框架:加载与卸载

Linux 驱动以内核模块的形式存在。模块使用 module_init 宏指定入口函数,使用 module_exit 宏指定出口函数。这两个函数是驱动与内核交互的第一道关口。

宏/函数 作用 调用时机
module_init(func) 注册模块加载时调用的初始化函数 insmod 时执行
module_exit(func) 注册模块卸载时调用的清理函数 rmmod 时执行
MODULE_LICENSE("GPL") 声明模块许可证,避免内核污染 编译时静态声明
MODULE_AUTHOR("name") 声明模块作者信息 编译时静态声明
关键点: 初始化函数必须返回 0 表示成功,返回负值表示失败。卸载函数负责释放所有已申请的资源,否则会导致资源泄漏。

下面是最简模块框架代码:

#include <linux/module.h>
#include <linux/kernel.h>

static int __init mydrv_init(void)
{
    printk(KERN_INFO "mydrv: module loaded\n");
    return 0;
}

static void __exit mydrv_exit(void)
{
    printk(KERN_INFO "mydrv: module unloaded\n");
}

module_init(mydrv_init);
module_exit(mydrv_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Driver Expert");
MODULE_DESCRIPTION("A simple char device driver skeleton");
模块加载与卸载流程 insmod mydrv.ko rmmod mydrv module_init() → mydrv_init() module_exit() → mydrv_exit() printk "module loaded" printk "module unloaded" insmod 触发 module_init → rmmod 触发 module_exit → 内核日志记录状态

3.2 设备号分配:动态 vs 静态

每个字符设备在内核中由 设备号 唯一标识。设备号分为主设备号(major)和次设备号(minor)。主设备号对应驱动,次设备号对应同一驱动管理的不同设备实例。

分配方式 函数 优点 缺点
静态分配 register_chrdev_region() 设备号固定,便于用户空间 mknod 需提前知道未使用的主设备号,容易冲突
动态分配 alloc_chrdev_region() 自动分配未使用的主设备号,无冲突 设备号不固定,需通过 /proc/devices 查看
实践建议: 在开发阶段优先使用动态分配,避免与现有驱动冲突。产品定型后,可申请固定主设备号或继续使用动态分配并配合 udev 自动创建设备节点。

动态分配设备号示例:

#include <linux/fs.h>

#define DEVICE_NAME "mydrv"
#define MINOR_COUNT 1

static dev_t dev_num;  // 存储分配到的设备号

static int __init mydrv_init(void)
{
    int ret;

    // 动态分配设备号:第一个参数是输出,第二个是次设备号起始,第三个是数量,第四个是名称
    ret = alloc_chrdev_region(&dev_num, 0, MINOR_COUNT, DEVICE_NAME);
    if (ret < 0) {
        printk(KERN_ERR "mydrv: failed to allocate device number\n");
        return ret;
    }

    printk(KERN_INFO "mydrv: allocated major=%d minor=%d\n",
           MAJOR(dev_num), MINOR(dev_num));
    return 0;
}

static void __exit mydrv_exit(void)
{
    unregister_chrdev_region(dev_num, MINOR_COUNT);
    printk(KERN_INFO "mydrv: device number released\n");
}

3.3 注册字符设备:cdev 结构体

分配设备号后,需要将设备号与驱动操作函数绑定。内核使用 struct cdev 表示一个字符设备,通过 cdev_init() 和 cdev_add() 完成注册。

注册流程分为三步:

  1. 初始化 cdev: cdev_init(&cdev, &fops) 将文件操作结构体与 cdev 关联
  2. 设置所有者: cdev.owner = THIS_MODULE 防止模块被意外卸载
  3. 添加到内核: cdev_add(&cdev, dev_num, count) 将设备注册到系统
#include <linux/cdev.h>

static struct cdev my_cdev;

static int __init mydrv_init(void)
{
    int ret;

    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) return ret;

    // 2. 初始化 cdev
    cdev_init(&my_cdev, &mydrv_fops);
    my_cdev.owner = THIS_MODULE;

    // 3. 添加 cdev 到内核
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        unregister_chrdev_region(dev_num, 1);
        printk(KERN_ERR "mydrv: cdev_add failed\n");
        return ret;
    }

    printk(KERN_INFO "mydrv: char device registered (major=%d)\n", MAJOR(dev_num));
    return 0;
}

static void __exit mydrv_exit(void)
{
    cdev_del(&my_cdev);          // 从内核移除 cdev
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "mydrv: char device unregistered\n");
}
字符设备注册流程架构 步骤1: 分配设备号 alloc_chrdev_region() 步骤2: 初始化 cdev cdev_init() + owner 步骤3: 添加 cdev cdev_add() 内核 VFS 层 通过主设备号查找 cdev cdev_map 哈希表 主设备号 → cdev 映射 file_operations open/release/... 用户空间: mknod /dev/mydrv c major 0 → open("/dev/mydrv")

3.4 实现 open 和 release 操作

file_operations 结构体是驱动与 VFS 层的接口。本章我们实现最基本的两个回调:open 和 release。

回调函数 触发时机 典型实现
open 用户调用 open() 系统调用时 递增使用计数、初始化硬件、分配私有数据
release 用户调用 close() 系统调用时 递减使用计数、释放资源、关闭硬件
#include <linux/fs.h>
#include <linux/uaccess.h>

static int mydrv_open(struct inode *inode, struct file *filp)
{
    // inode 包含设备号信息,filp 是文件描述符的内核表示
    printk(KERN_INFO "mydrv: device opened (major=%d, minor=%d)\n",
           imajor(inode), iminor(inode));

    // 示例:将私有数据指针指向设备结构体(后续章节扩展)
    // filp->private_data = my_dev;

    return 0;  // 0 表示成功
}

static int mydrv_release(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "mydrv: device released\n");
    return 0;
}

// 定义文件操作结构体
static struct file_operations mydrv_fops = {
    .owner   = THIS_MODULE,
    .open    = mydrv_open,
    .release = mydrv_release,
};
注意: open 返回 0 表示成功,返回负值(如 -ENODEV、-EBUSY)表示打开失败。release 通常返回 0,内核忽略其返回值。

3.5 完整驱动代码与 Makefile

将以上所有片段整合为完整的驱动文件 mydrv.c:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>

#define DEVICE_NAME "mydrv"
#define MINOR_COUNT 1

static dev_t dev_num;
static struct cdev my_cdev;

static int mydrv_open(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "mydrv: opened (major=%d, minor=%d)\n",
           imajor(inode), iminor(inode));
    return 0;
}

static int mydrv_release(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "mydrv: released\n");
    return 0;
}

static struct file_operations mydrv_fops = {
    .owner   = THIS_MODULE,
    .open    = mydrv_open,
    .release = mydrv_release,
};

static int __init mydrv_init(void)
{
    int ret;

    ret = alloc_chrdev_region(&dev_num, 0, MINOR_COUNT, DEVICE_NAME);
    if (ret < 0) {
        printk(KERN_ERR "mydrv: alloc_chrdev_region failed\n");
        return ret;
    }

    cdev_init(&my_cdev, &mydrv_fops);
    my_cdev.owner = THIS_MODULE;

    ret = cdev_add(&my_cdev, dev_num, MINOR_COUNT);
    if (ret < 0) {
        unregister_chrdev_region(dev_num, MINOR_COUNT);
        printk(KERN_ERR "mydrv: cdev_add failed\n");
        return ret;
    }

    printk(KERN_INFO "mydrv: loaded, major=%d\n", MAJOR(dev_num));
    return 0;
}

static void __exit mydrv_exit(void)
{
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, MINOR_COUNT);
    printk(KERN_INFO "mydrv: unloaded\n");
}

module_init(mydrv_init);
module_exit(mydrv_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Driver Expert");
MODULE_DESCRIPTION("First char device driver with open/release");

配套 Makefile:

# Makefile for mydrv
obj-m := mydrv.o

KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

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

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

install:
	sudo insmod mydrv.ko

remove:
	sudo rmmod mydrv

info:
	cat /proc/devices | grep mydrv

3.6 在 ARM 开发板上验证

将编译好的 mydrv.ko 传输到 ARM 开发板,按以下步骤验证:

  1. 加载模块: insmod mydrv.ko
  2. 查看设备号: cat /proc/devices | grep mydrv 记录主设备号
  3. 创建设备节点: mknod /dev/mydrv c <major> 0
  4. 测试 open/release: exec 3</dev/mydrv; echo "opened"; exec 3<&-
  5. 查看内核日志: dmesg | tail -10 确认 open/release 打印
  6. 卸载模块: rmmod mydrv
验证结果示例(dmesg 输出):
[ 1234.567890] mydrv: loaded, major=240
[ 1235.123456] mydrv: opened (major=240, minor=0)
[ 1235.234567] mydrv: released
[ 1236.345678] mydrv: unloaded
驱动验证流程(ARM 开发板) insmod cat /proc/devices mknod open/close dmesg rmmod 验证要点 ① 确认 insmod 无错误 ② 记录主设备号 ③ mknod 创建节点 ④ open/close 触发驱动回调 ⑤ dmesg 查看日志 ⑥ rmmod 卸载干净 常见问题:设备节点权限不足 → chmod 666 /dev/mydrv;主设备号冲突 → 改用动态分配

3.7 常见错误与调试

错误现象 可能原因 解决方案
insmod: ERROR: could not insert module 内核版本不匹配、符号缺失 检查 uname -r 与编译内核头文件版本一致
open(/dev/mydrv) failed: No such device 设备节点主设备号与驱动不匹配 重新 cat /proc/devices 确认主设备号后重建节点
open(/dev/mydrv) failed: Permission denied 设备节点权限不足 chmod 666 /dev/mydrv 或使用 root 用户
dmesg 无任何输出 printk 日志级别被抑制 使用 printk(KERN_ALERT ...) 或调整 /proc/sys/kernel/printk
rmmod: ERROR: Module mydrv is in use 有进程持有设备文件描述符 lsof /dev/mydrv 查找并关闭进程
内核调试黄金法则: 当驱动行为异常时,第一件事是检查 dmesg 输出。内核不会像用户空间程序那样崩溃打印段错误,而是通过 printk 输出诊断信息。在开发阶段,建议在 open、release 以及所有可能出错的路径上都加上 printk 日志。

3.8 本章小结

  • 内核模块使用 module_init / module_exit 管理加载和卸载生命周期
  • 设备号通过 alloc_chrdev_region 动态分配,避免冲突
  • cdev_init + cdev_add 将字符设备注册到内核 VFS 层
  • file_operations 中的 open 和 release 是驱动与用户空间交互的入口
  • 完整的验证流程:insmod → 查看设备号 → mknod → open/close → dmesg → rmmod