4. 字符设备驱动框架

字符设备是Linux驱动开发中最基础、最常见的一类设备。它按字节流方式访问,典型代表有串口、按键、LED、I2C从设备等。要写出一个可加载、可被用户空间访问的字符设备驱动,必须理解四个核心概念:主次设备号、cdev、file_operations以及设备节点。本节将围绕这四者,梳理字符设备驱动的完整框架与注册流程。

一、主次设备号:内核识别设备的“身份证”

在Linux中,每个字符设备都由一个设备号(dev_t)唯一标识。dev_t是一个32位整数,其中高12位为主设备号(major),低20位为次设备号(minor)。主设备号用于标识设备对应的驱动程序,次设备号用于区分同一驱动下的不同设备实例。

/* 内核中 dev_t 的构成 */
typedef __u32 dev_t;   // 高12位 major,低20位 minor

/* 常用操作宏 */
MAJOR(dev_t dev);      // 提取主设备号
MINOR(dev_t dev);      // 提取次设备号
MKDEV(int major, int minor);  // 合成设备号

设备号有两种分配方式:静态指定和动态分配。静态指定使用register_chrdev_region(),需要开发者提前确定一个未被占用的主设备号;动态分配使用alloc_chrdev_region(),由内核自动分配可用主设备号,推荐在驱动中使用,可避免冲突。

查看当前系统已占用的设备号,可执行 cat /proc/devices,其中“Character devices”段列出了所有已注册的字符设备主设备号及名称。

二、cdev:内核中的字符设备对象

struct cdev是内核用来描述一个字符设备的结构体,它把设备号与file_operations绑定在一起。驱动中通常将cdev嵌入到自定义的设备结构体中,便于管理私有数据。

struct cdev {
    struct kobject kobj;
    struct module *owner;
    const struct file_operations *ops;
    struct list_head list;
    dev_t dev;
    unsigned int count;
};

/* 常用API */
void cdev_init(struct cdev *cdev, const struct file_operations *fops);
int cdev_add(struct cdev *cdev, dev_t dev, unsigned int count);
void cdev_del(struct cdev *cdev);

cdev的生命周期通常为:cdev_init()初始化并绑定fops → cdev_add()添加到内核 → 模块卸载时cdev_del()删除。注意cdev_add之后设备即可被访问,因此必须确保所有资源已就绪。

三、file_operations:用户空间与驱动的桥梁

struct file_operations是字符设备驱动的核心,它定义了一组函数指针,对应用户空间对设备文件的操作。当应用程序调用open/read/write/ioctl时,VFS会通过设备号找到对应的cdev,再调用fops中注册的函数。

成员函数对应系统调用典型用途
openopen()初始化设备、申请资源
releaseclose()释放资源、关闭设备
readread()从设备读取数据到用户空间
writewrite()将用户数据写入设备
unlocked_ioctlioctl()执行设备特定控制命令
llseeklseek()定位读写位置
mmapmmap()将设备内存映射到用户空间

编写fops时需注意:函数原型必须与内核定义严格一致;__user标记的指针必须使用copy_to_user()或copy_from_user()访问,不能直接解引用。

四、register_chrdev:传统注册方式与设备节点创建

早期内核提供register_chrdev()一次性完成设备号申请和cdev注册,使用简单但不够灵活:它会占用一个主设备号下的所有次设备号。

static int major;
static struct file_operations my_fops = {
    .owner = THIS_MODULE,
    .open  = my_open,
    .read  = my_read,
    .write = my_write,
    .release = my_release,
};

static int __init my_init(void)
{
    major = register_chrdev(0, "mydev", &my_fops);
    if (major < 0)
        return major;
    printk("mydev major = %d\n", major);
    return 0;
}

static void __exit my_exit(void)
{
    unregister_chrdev(major, "mydev");
}

模块加载后,需要创建设备节点才能被用户空间访问。设备节点是/dev目录下的特殊文件,记录了设备号。创建方式有两种:

  • 手动创建:mknod /dev/mydev c 240 0,其中c表示字符设备,240为主设备号,0为次设备号。
  • 自动创建:使用class_create()和device_create(),由udev/mdev在/dev下自动生成节点,这是现代驱动的标准做法。
/* 自动创建设备节点 */
static struct class *my_class;
static struct device *my_device;

my_class = class_create(THIS_MODULE, "mydev");
my_device = device_create(my_class, NULL, MKDEV(major, 0), NULL, "mydev");
使用register_chrdev时,若传入major=0,内核会动态分配主设备号,但无法精确控制次设备号范围。生产驱动建议使用alloc_chrdev_region + cdev_add的组合,粒度更细。

五、完整框架与数据流

下图展示了从用户空间系统调用到驱动函数的完整路径,以及主次设备号、cdev、file_operations、设备节点之间的对应关系。

用户空间 应用程序 open/read/write /dev/mydev 设备节点 主设备号 240 次设备号 0 内核空间 VFS 虚拟文件系统 按 dev_t 查找 cdev struct cdev dev = MKDEV(240,0) file_operations open/read/write/ioctl 驱动实现函数:my_open / my_read / my_write / my_release copy_to_user / copy_from_user 完成数据交换 注册流程 1. alloc_chrdev_region() 2. cdev_init() 3. cdev_add() 4. class_create() 5. device_create() 卸载顺序:device_destroy → class_destroy → cdev_del → unregister_chrdev_region 顺序与注册相反,避免资源泄漏

六、常见误区与复盘

  • 设备号冲突:静态指定主设备号前未检查/proc/devices,导致注册失败。建议优先使用动态分配。
  • 忘记cdev_del:模块卸载时只调用unregister_chrdev_region而未删除cdev,会造成内核对象残留。
  • fops函数原型错误:如read返回值类型写成void,编译可通过但运行异常。务必对照内核头文件。
  • 直接访问用户指针:未使用copy_to_user/copy_from_user,可能导致内核崩溃或安全漏洞。
  • 设备节点未创建:驱动加载成功但/dev下无节点,应用无法打开。检查class/device_create是否成功,以及udev是否运行。
掌握字符设备驱动框架的关键在于理解“设备号→cdev→fops→设备节点”这条链路。register_chrdev适合快速验证,而alloc_chrdev_region+cdev_add+class_create的组合才是现代驱动的标准写法。下一节将在此基础上实现第一个完整的字符设备驱动。