3.1 字符设备驱动基础框架

字符设备是 Linux 驱动中最基础的设备类型,以字节流方式读写数据。本章从零搭建一个完整的字符设备驱动骨架,涵盖模块生命周期、设备号管理、文件操作接口注册等核心环节。

学习目标:掌握模块加载/卸载宏的使用、设备号动态分配与注销、file_operations 结构体填充、以及简单的 open/release 回调实现。

3.1.1 模块加载与卸载

Linux 驱动以内核模块形式存在,通过 module_init() 和 module_exit() 宏指定入口和出口函数。模块加载时完成资源申请,卸载时释放资源。

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

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

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

module_init(mychar_init);
module_exit(mychar_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Driver Expert");
MODULE_DESCRIPTION("Simple char device driver example");
关键点:__init 和 __exit 标记用于优化内存布局,加载完成后 init 函数占用的内存可被回收。
模块加载/卸载生命周期 insmod 加载 module_init() 设备注册 设备注销 module_exit() rmmod 卸载 加载与卸载路径必须对称,否则可能导致资源泄漏或内核崩溃

3.1.2 设备号注册与注销

每个字符设备在系统中由主设备号和次设备号唯一标识。主设备号对应驱动,次设备号对应具体设备实例。

概念 说明 范围
主设备号 标识设备驱动程序 0~4095(12位)
次设备号 标识具体设备实例 0~1048575(20位)
静态注册 手动指定主设备号 容易冲突,不推荐
动态分配 内核自动分配空闲号 推荐方式
#include <linux/fs.h>
#include <linux/cdev.h>

#define DEVICE_NAME "mychar"
static dev_t dev_num;          // 设备号
static struct cdev my_cdev;    // 字符设备对象

static int __init mychar_init(void)
{
    int ret;
    
    // 1. 动态分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        printk(KERN_ERR "Failed to allocate device number\n");
        return ret;
    }
    printk(KERN_INFO "Allocated dev: %d:%d\n", 
           MAJOR(dev_num), MINOR(dev_num));
    
    // 2. 初始化 cdev 并关联 file_operations
    cdev_init(&my_cdev, &mychar_fops);
    my_cdev.owner = THIS_MODULE;
    
    // 3. 添加字符设备到系统
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        printk(KERN_ERR "Failed to add cdev\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }
    
    return 0;
}

static void __exit mychar_exit(void)
{
    cdev_del(&my_cdev);                    // 删除字符设备
    unregister_chrdev_region(dev_num, 1);  // 注销设备号
    printk(KERN_INFO "mychar: device unregistered\n");
}
注意事项:设备号是系统全局资源,卸载模块时必须注销,否则下次加载时可能分配不同的设备号,导致用户空间程序无法找到设备。

3.1.3 file_operations 结构体

file_operations 是字符设备驱动的核心接口表,定义了用户空间通过系统调用操作设备时的内核回调函数。

static int mychar_open(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "mychar: device opened\n");
    return 0;
}

static int mychar_release(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "mychar: device closed\n");
    return 0;
}

// 定义 file_operations 结构体
static struct file_operations mychar_fops = {
    .owner   = THIS_MODULE,
    .open    = mychar_open,
    .release = mychar_release,
    // .read  = mychar_read,    // 后续章节实现
    // .write = mychar_write,   // 后续章节实现
    // .unlocked_ioctl = mychar_ioctl, // 后续章节实现
};
file_operations 结构体与系统调用映射 用户空间应用程序 系统调用接口 虚拟文件系统 (VFS) file_operations .open = mychar_open .release = mychar_release .read = mychar_read .write = mychar_write .unlocked_ioctl 绿色:已实现 橙色:后续章节实现 每个系统调用通过 VFS 查找对应驱动的 file_operations 回调

3.1.4 open 与 release 实现

open 在用户空间调用 open() 系统调用时触发,release 在最后一个文件描述符关闭时触发。这两个回调是驱动与用户空间交互的入口和出口。

static int mychar_open(struct inode *inode, struct file *filp)
{
    // 获取次设备号
    unsigned int minor = iminor(inode);
    
    // 权限检查示例
    if ((filp->f_flags & O_ACCMODE) == O_WRONLY) {
        printk(KERN_INFO "mychar: opened for write only (minor=%u)\n", minor);
    }
    
    // 保存私有数据(后续章节使用)
    filp->private_data = NULL;
    
    printk(KERN_INFO "mychar: open minor=%u, pid=%d\n", 
           minor, current->pid);
    return 0;
}

static int mychar_release(struct inode *inode, struct file *filp)
{
    unsigned int minor = iminor(inode);
    
    // 清理私有数据
    if (filp->private_data) {
        kfree(filp->private_data);
        filp->private_data = NULL;
    }
    
    printk(KERN_INFO "mychar: release minor=%u, pid=%d\n", 
           minor, current->pid);
    return 0;
}
最佳实践:在 open 中分配资源,在 release 中释放资源,确保资源生命周期与文件描述符绑定,避免泄漏。

3.1.5 完整驱动框架

将上述组件整合为完整的字符设备驱动模板:

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

#define DEVICE_NAME "mychar"
#define MAX_DEVICES 4

static dev_t dev_base;
static struct cdev my_cdevs[MAX_DEVICES];

static int mychar_open(struct inode *inode, struct file *filp)
{
    unsigned int minor = iminor(inode);
    if (minor >= MAX_DEVICES)
        return -ENODEV;
    
    filp->private_data = (void *)(unsigned long)minor;
    printk(KERN_INFO "mychar: open minor=%u\n", minor);
    return 0;
}

static int mychar_release(struct inode *inode, struct file *filp)
{
    unsigned int minor = (unsigned long)filp->private_data;
    printk(KERN_INFO "mychar: release minor=%u\n", minor);
    return 0;
}

static struct file_operations mychar_fops = {
    .owner   = THIS_MODULE,
    .open    = mychar_open,
    .release = mychar_release,
};

static int __init mychar_init(void)
{
    int ret, i;
    
    ret = alloc_chrdev_region(&dev_base, 0, MAX_DEVICES, DEVICE_NAME);
    if (ret < 0) {
        pr_err("Failed to allocate device numbers\n");
        return ret;
    }
    
    for (i = 0; i < MAX_DEVICES; i++) {
        cdev_init(&my_cdevs[i], &mychar_fops);
        my_cdevs[i].owner = THIS_MODULE;
        ret = cdev_add(&my_cdevs[i], dev_base + i, 1);
        if (ret) {
            pr_err("Failed to add cdev for minor %d\n", i);
            goto err_cleanup;
        }
    }
    
    pr_info("mychar: loaded with major=%d, devices=%d\n", 
            MAJOR(dev_base), MAX_DEVICES);
    return 0;

err_cleanup:
    while (--i >= 0)
        cdev_del(&my_cdevs[i]);
    unregister_chrdev_region(dev_base, MAX_DEVICES);
    return ret;
}

static void __exit mychar_exit(void)
{
    int i;
    for (i = 0; i < MAX_DEVICES; i++)
        cdev_del(&my_cdevs[i]);
    unregister_chrdev_region(dev_base, MAX_DEVICES);
    pr_info("mychar: unloaded\n");
}

module_init(mychar_init);
module_exit(mychar_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Driver Expert");
MODULE_DESCRIPTION("Complete char device driver skeleton");
字符设备驱动完整架构 用户空间 系统调用 内核空间 module_init module_exit file_operations 设备号管理 驱动加载流程 1. module_init → alloc_chrdev_region 2. cdev_init + cdev_add 3. 设备节点创建 (mknod/udev) 驱动卸载流程 1. module_exit → cdev_del 2. unregister_chrdev_region 3. 删除设备节点

3.1.6 编译与测试

使用 Makefile 编译驱动模块,并通过用户空间程序验证基本功能:

# Makefile
obj-m += mychar.o

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

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

clean:
    $(MAKE) -C $(KDIR) M=$(PWD) clean
# 编译与测试命令
$ make
$ sudo insmod mychar.ko
$ dmesg | tail -5
[12345.678901] mychar: loaded with major=240, devices=4

# 创建设备节点
$ sudo mknod /dev/mychar0 c 240 0
$ sudo mknod /dev/mychar1 c 240 1

# 测试 open/release
$ cat /dev/mychar0
$ dmesg | tail -2
[12346.123456] mychar: open minor=0
[12346.123789] mychar: release minor=0

# 卸载模块
$ sudo rmmod mychar
$ dmesg | tail -1
[12347.987654] mychar: unloaded
验证要点:
  • 使用 lsmod | grep mychar 确认模块已加载
  • 使用 cat /proc/devices | grep mychar 查看设备号
  • 使用 dmesg 观察驱动打印的日志信息
  • 确保卸载后 /proc/devices 中不再出现该设备

3.1.7 常见问题与排查

问题现象 可能原因 解决方案
insmod 返回错误码 -1 设备号分配失败或 cdev_add 失败 检查 dmesg 错误信息,确认主设备号未冲突
open 返回 -ENODEV 设备节点主/次设备号不匹配 使用 ls -l /dev/mychar* 核对设备号
rmmod 提示模块正忙 有进程持有设备文件描述符 使用 lsof /dev/mychar* 查找占用进程
dmesg 无输出 日志级别过滤或 printk 未生效 检查 /proc/sys/kernel/printk 级别设置
调试技巧:在驱动开发初期,大量使用 printk 输出关键路径信息,配合 dmesg -w 实时查看日志,是最高效的调试手段。