news 2026/4/16 20:49:52

Linux 字符设备驱动从入门到精通:从 register_chrdev 到 cdev 的演进实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux 字符设备驱动从入门到精通:从 register_chrdev 到 cdev 的演进实践

Linux 字符设备驱动从入门到精通:从 register_chrdev 到 cdev 的演进实践

目录

  1. 前言
  2. 核心概念梳理
    • 2.1 设备号:主设备号与次设备号
    • 2.2 关键数据结构:struct filestruct file_operations
    • 2.3struct cdev:字符设备的化身
    • 2.4 设备类与设备节点自动创建
  3. 旧接口register_chrdev的工作方式与缺陷
  4. 现代 cdev 接口详解
    • 4.1 分步注册流程
    • 4.2 各函数参数精讲
    • 4.3 错误处理与资源回滚
  5. 实战:编写一个支持读写的小驱动
    • 5.1 驱动程序完整代码(cdev 版本)
    • 5.2 关键代码逐行剖析
    • 5.3 用户空间测试程序
  6. 编译、加载与测试全流程
    • 6.1 Makefile 编写
    • 6.2 编译与解决警告
    • 6.3 模块的加载与卸载
    • 6.4 功能测试与现象解释
  7. 常见问题与调试技巧
    • 7.1insmod: File exists错误排查
    • 7.2 设备号查看方法
    • 7.3 时间戳异常分析
    • 7.4 如何确认当前运行的驱动版本
  8. 总结与扩展
  9. 自测题(附答案)
  10. 完整代码下载

前言

在 Linux 系统中,设备驱动是连接硬件与用户程序的桥梁。字符设备驱动是最常见的一类驱动,它把硬件抽象为一个文件(/dev/xxx),应用程序通过标准的openreadwriteclose等系统调用即可操作硬件。

Linux 内核在发展过程中,字符设备驱动的注册接口经历了从register_chrdev到基于cdev的现代化接口的演进。本文将以一个最简单的“hello 驱动”为例,带你从零开始掌握字符设备驱动的编写,并深入理解两种接口的差异与适用场景。

本文内容基于 Linux 4.9.88 内核,测试平台为 NXP i.MX6ULL 开发板,但原理适用于所有 Linux 版本。


核心概念梳理

2.1 设备号:主设备号与次设备号

在 Linux 中,每个字符设备都由一个设备号唯一标识。设备号是一个 32 位无符号整数(dev_t类型),其中高 12 位代表主设备号,低 20 位代表次设备号

  • 主设备号:标识设备对应的驱动程序。同一个驱动程序管理的所有设备通常共享同一个主设备号。
  • 次设备号:由驱动程序内部使用,用于区分同一驱动管理的不同具体设备。例如,串口驱动中/dev/ttyS0/dev/ttyS1主设备号相同,次设备号不同。

可以通过以下宏操作设备号:

c

dev_t dev = MKDEV(major, minor); // 组合成设备号 int major = MAJOR(dev); // 提取主设备号 int minor = MINOR(dev); // 提取次设备号

2.2 关键数据结构:struct filestruct file_operations

当应用程序调用open("/dev/hello", ...)时,内核会为该次打开操作创建一个struct file对象,它记录了本次打开的状态信息:

c

struct file { fmode_t f_mode; // 读写权限 loff_t f_pos; // 当前文件偏移量 unsigned int f_flags; // 打开标志(如 O_RDWR) const struct file_operations *f_op; // 指向操作函数集 void *private_data; // 驱动私有数据指针 // ... 其他成员 };

struct file_operations则是驱动开发者需要填充的核心结构体,它将系统调用与驱动函数进行绑定:

c

struct file_operations { struct module *owner; loff_t (*llseek) (struct file *, loff_t, int); ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); int (*open) (struct inode *, struct file *); int (*release) (struct inode *, struct file *); // ... 还有很多其他操作 };

在我们的驱动中,只实现了最基础的openreadwriterelease

2.3struct cdev:字符设备的化身

struct cdev是内核用来描述一个字符设备的结构体:

c

struct cdev { struct kobject kobj; // 内核对象基础结构 struct module *owner; // 所属模块 const struct file_operations *ops; // 操作函数集 struct list_head list; // 链接到内核的 cdev 链表 dev_t dev; // 起始设备号 unsigned int count; // 管理的次设备号数量 };

当我们向内核注册一个cdev后,内核就会在内部散列表中记录一条映射:“从某设备号开始的连续 N 个设备号,均由该 cdev 处理”。此后,任何打开这些设备号的请求,都会调用该cdev关联的file_operations中的函数。

2.4 设备类与设备节点自动创建

早期的 Linux 系统中,设备节点需要手动使用mknod命令创建,十分繁琐。现代内核通过设备模型提供了自动创建设备节点的机制,涉及两个关键函数:

  • class_create(owner, name):在/sys/class/下创建一个设备类。
  • device_create(class, parent, devt, drvdata, fmt, ...):在/dev/下自动创建一个设备节点,并触发udevmdev设置权限。

这种方式使得模块加载后,设备节点自动出现;模块卸载后,节点自动消失,极大方便了开发和部署。


旧接口register_chrdev的工作方式与缺陷

函数原型

c

int register_chrdev(unsigned int major, const char *name, const struct file_operations *fops);
  • major0,则由内核动态分配一个主设备号,并返回该主设备号。
  • 该函数一次性完成三件事:分配设备号注册 cdev绑定 fops
  • 问题:无论你实际需要多少个次设备号,该函数会固定占用 256 个次设备号(0~255)。对于只需要一个设备节点的简单驱动,这是巨大的资源浪费。

卸载接口

c

void unregister_chrdev(unsigned int major, const char *name);

释放之前占用的主设备号和次设备号范围。

示例代码(旧方式)

c

static int major; static int __init hello_init(void) { major = register_chrdev(0, "100ask_hello", &hello_drv); // ... 创建 class 和 device return 0; } static void __exit hello_exit(void) { // ... 销毁 device 和 class unregister_chrdev(major, "100ask_hello"); }

虽然这种方式简单直观,但由于其资源浪费且不符合现代内核的设计哲学,Linux 2.6 之后推荐使用更精细的cdev接口。


现代 cdev 接口详解

4.1 分步注册流程

现代接口将设备号申请、cdev 初始化、cdev 注册分成独立的步骤:

  1. 申请设备号alloc_chrdev_region()register_chrdev_region()
  2. 初始化 cdevcdev_init()
  3. 向内核添加 cdevcdev_add()

同时,错误处理中必须按照“后申请的先释放”的原则进行回滚。

4.2 各函数参数精讲

4.2.1alloc_chrdev_region

c

int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
  • dev:输出参数,内核将分配到的起始设备号存入其中。
  • baseminor:申请的起始次设备号,通常填0
  • count:申请多少个连续的次设备号。按需申请,例如只需要一个设备就填1
  • name:设备名称,会显示在/proc/devices中。
  • 返回值:成功返回0,失败返回负的错误码。
4.2.2cdev_init

c

void cdev_init(struct cdev *cdev, const struct file_operations *fops);
  • fops绑定到cdev->ops,并初始化cdev的其他字段。
4.2.3cdev_add

c

int cdev_add(struct cdev *cdev, dev_t dev, unsigned count);
  • cdev:已初始化的 cdev 对象。
  • dev:该 cdev 管理的起始设备号。
  • count:该 cdev 管理的连续次设备号数量(必须 ≤alloc_chrdev_region中申请的数量)。
  • 函数执行后,内核将记录映射关系。
4.2.4 注销顺序

卸载时,必须严格按相反顺序释放资源:

c

cdev_del(&hello_cdev); // 1. 移除 cdev unregister_chrdev_region(dev, count); // 2. 释放设备号

注意cdev_del必须在unregister_chrdev_region之前调用,以避免设备号被新驱动抢占后仍可访问到旧的 cdev。

4.3 错误处理与资源回滚

由于资源是分步申请的,如果中间某一步失败,必须将之前已申请的资源释放掉,否则会造成资源泄漏。

初始化函数中的典型回滚逻辑

c

ret = alloc_chrdev_region(&dev, 0, 2, "hello"); if (ret) return ret; cdev_init(&hello_cdev, &fops); ret = cdev_add(&hello_cdev, dev, 2); if (ret) { unregister_chrdev_region(dev, 2); // 回滚第一步 return ret; } hello_class = class_create(THIS_MODULE, "hello_class"); if (IS_ERR(hello_class)) { cdev_del(&hello_cdev); // 回滚第二步 unregister_chrdev_region(dev, 2); // 回滚第一步 return PTR_ERR(hello_class); }

教学代码中有时会省略回滚以保持简洁,但在生产级驱动中,完整的错误处理是必须的。


实战:编写一个支持读写的小驱动

5.1 驱动程序完整代码(cdev 版本)

文件名:hello_drv.c

c

#include <linux/cdev.h> #include <linux/device.h> #include <linux/fs.h> #include <linux/init.h> #include <linux/module.h> #include <linux/uaccess.h> static struct class *hello_class; static struct cdev hello_cdev; static dev_t dev; static unsigned char hello_buf[100]; static int hello_open(struct inode *node, struct file *filp) { printk(KERN_INFO "%s %s %d\n", __FILE__, __func__, __LINE__); return 0; } static ssize_t hello_read(struct file *filp, char __user *buf, size_t size, loff_t *offset) { unsigned long len = size > 100 ? 100 : size; printk(KERN_INFO "%s %s %d\n", __FILE__, __func__, __LINE__); if (copy_to_user(buf, hello_buf, len)) return -EFAULT; return len; } static ssize_t hello_write(struct file *filp, const char __user *buf, size_t size, loff_t *offset) { unsigned long len = size > 100 ? 100 : size; printk(KERN_INFO "%s %s %d\n", __FILE__, __func__, __LINE__); if (copy_from_user(hello_buf, buf, len)) return -EFAULT; return len; } static int hello_release(struct inode *node, struct file *filp) { printk(KERN_INFO "%s %s %d\n", __FILE__, __func__, __LINE__); return 0; } static const struct file_operations hello_drv = { .owner = THIS_MODULE, .read = hello_read, .write = hello_write, .open = hello_open, .release = hello_release, }; static int __init hello_init(void) { int ret; /* 1. 动态申请设备号,次设备号从0开始,申请2个 */ ret = alloc_chrdev_region(&dev, 0, 2, "hello"); if (ret < 0) { printk(KERN_ERR "alloc_chrdev_region failed\n"); return ret; } /* 2. 初始化 cdev 并绑定 file_operations */ cdev_init(&hello_cdev, &hello_drv); /* 3. 向内核注册 cdev */ ret = cdev_add(&hello_cdev, dev, 2); if (ret) { printk(KERN_ERR "cdev_add failed\n"); unregister_chrdev_region(dev, 2); return ret; } /* 4. 创建设备类 */ hello_class = class_create(THIS_MODULE, "hello_class"); if (IS_ERR(hello_class)) { printk(KERN_ERR "class_create failed\n"); cdev_del(&hello_cdev); unregister_chrdev_region(dev, 2); return PTR_ERR(hello_class); } /* 5. 自动创建设备节点 /dev/hello */ device_create(hello_class, NULL, dev, NULL, "hello"); printk(KERN_INFO "hello driver initialized.\n"); return 0; } static void __exit hello_exit(void) { device_destroy(hello_class, dev); class_destroy(hello_class); cdev_del(&hello_cdev); unregister_chrdev_region(dev, 2); printk(KERN_INFO "hello driver removed.\n"); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple cdev-based hello driver");

5.2 关键代码逐行剖析

(1)头文件

c

#include <linux/cdev.h> // cdev 相关函数 #include <linux/device.h> // class_create, device_create #include <linux/fs.h> // alloc_chrdev_region, file_operations #include <linux/uaccess.h> // copy_to/from_user
(2)全局变量

c

static dev_t dev; // 设备号 static struct cdev hello_cdev; // cdev 对象 static struct class *hello_class;// 设备类指针 static unsigned char hello_buf[100]; // 数据缓冲区
(3)copy_to_usercopy_from_user

用户空间与内核空间不能直接通过指针访问对方的内存,必须使用专用函数:

  • copy_to_user(to, from, n):从内核拷贝到用户空间。
  • copy_from_user(to, from, n):从用户空间拷贝到内核。

两个函数均返回未成功拷贝的字节数,成功返回0。示例中我们增加了错误判断,若拷贝失败则返回-EFAULT

(4)申请数量为何是2而不是1

本文申请了 2 个次设备号(minor 0 和 minor 1),但只创建了/dev/hello(使用 minor 0)。这样做是为了演示cdev_add可以一次性管理多个次设备号,同时为后续扩展预留空间。实际产品中应根据需要精确申请。

5.3 用户空间测试程序

文件名:hello_test.c

c

#include <stdio.h> #include <string.h> #include <fcntl.h> #include <unistd.h> /* * 用法: * 写: ./hello_test /dev/hello <string> * 读: ./hello_test /dev/hello */ int main(int argc, char **argv) { int fd; char buf[100]; if (argc < 2) { printf("Usage:\n"); printf(" %s <dev> [string]\n", argv[0]); return -1; } fd = open(argv[1], O_RDWR); if (fd < 0) { printf("can not open file %s\n", argv[1]); return -1; } if (argc == 3) { int len = write(fd, argv[2], strlen(argv[2]) + 1); printf("write ret = %d\n", len); } else { int len = read(fd, buf, sizeof(buf) - 1); buf[len] = '\0'; printf("read str: %s\n", buf); } close(fd); return 0; }

编译、加载与测试全流程

6.1 Makefile 编写

makefile

# 指定内核源码树路径(根据你的环境修改) KERN_DIR = /home/book/100ask_imx6ull-sdk/Linux-4.9.88 # 交叉编译工具链前缀(若为本地编译则无需设置) CROSS_COMPILE = arm-buildroot-linux-gnueabihf- all: make -C $(KERN_DIR) M=$(PWD) modules $(CROSS_COMPILE)gcc -o hello_test hello_test.c clean: make -C $(KERN_DIR) M=$(PWD) modules clean rm -rf modules.order Module.symvers rm -f hello_test # 目标模块文件名 obj-m += hello_drv.o

6.2 编译与解决警告

执行bear make后,可能会看到如下警告:

text

warning: ignoring return value of ‘copy_from_user’, declared with attribute warn_unused_result

这是因为未检查copy_*_user的返回值。一定要处理返回值,否则可能因用户传入非法地址导致内核崩溃。我们在代码中已加入错误判断,编译警告消失。

编译成功后,当前目录下会生成hello_drv.ko(内核模块)和hello_test(测试程序)。

6.3 模块的加载与卸载

将文件推送到开发板:

bash

adb push hello_drv.ko hello_test /root/

在开发板上执行:

bash

# 加载模块 insmod /root/hello_drv.ko # 查看内核日志 dmesg | tail -5 # 检查设备节点 ls -l /dev/hello # 查看设备号分配情况 cat /proc/devices | grep hello # 卸载模块 rmmod hello_drv

6.4 功能测试与现象解释

写入数据

bash

./hello_test /dev/hello 100ask

预期输出:write ret = 7(包括字符串结尾的\0

内核日志中可见hello_openhello_writehello_release的打印信息。

读出数据

bash

./hello_test /dev/hello

预期输出:read str: 100ask

内核日志中可见hello_openhello_readhello_release

验证多设备管理

由于申请了两个次设备号,我们可以手动创建次设备号为 1 的节点并测试:

bash

mknod /dev/abc c 244 1 # 主设备号请根据实际值修改 ./hello_test /dev/abc 1234 ./hello_test /dev/abc

你会看到读写/dev/abc同样成功,且数据与/dev/hello共享同一缓冲区(因为驱动内部未区分次设备号)。这也展示了如何利用次设备号区分不同设备实例。


常见问题与调试技巧

7.1insmod: File exists错误排查

现象:加载模块时提示insmod: ERROR: could not insert module hello_drv.ko: File exists

可能原因与解决方案

  1. 模块已加载

    bash

    lsmod | grep hello rmmod hello_drv
  2. 设备节点残留(模块已卸载但/dev/hello未删除)

    bash

    rm -f /dev/hello
  3. 设备号在/proc/devices中仍有记录(极少见,通常由于卸载函数不完善导致)
    重启系统是最简单的清理方法。

7.2 设备号查看方法

  • 查看主设备号cat /proc/devices | grep hello
  • 查看完整设备号ls -l /dev/hello输出中244, 0即为主设备号 244,次设备号 0。
  • 通过 sysfs 查看cat /sys/class/hello_class/hello/dev输出格式244:0

7.3 时间戳异常分析

在测试中,ls -l /dev/hello显示的时间戳可能是Jan 2 20:27,而当前系统时间为Jan 2 20:32 1970。这是因为:

  • 开发板没有 RTC 电池,每次上电系统时间重置为 1970-01-01 00:00:00(Unix 纪元)。
  • 设备节点在模块加载时被创建,因此时间戳为当时的系统时间。
  • 如果系统时间从未被同步,这个时间戳就会显示为 1970 年 1 月 1 日之后的某个时刻。

解决方法:使用ntpdate同步网络时间,或手动date -s "2026-04-16 15:30:00"设置。

7.4 如何确认当前运行的驱动版本

如果你频繁修改驱动代码,可能会担心加载的是旧版本。可以通过以下方法确认:

  • 在驱动初始化函数中加入版本打印

    c

    printk(KERN_INFO "hello_drv version: 2026-04-16-v2\n");

    然后查看dmesg

  • 查看模块文件的修改时间

    bash

    ls -l /root/hello_drv.ko
  • 检查/sys/module/hello_drv/目录(如果定义了模块参数或版本信息)。


总结与扩展

通过本文,你已掌握了:

  1. 字符设备驱动的核心概念:设备号、cdevfile_operations、设备模型。
  2. register_chrdevcdev接口的演进及其背后的设计思想。
  3. 完整编写、编译、测试一个基于 cdev 的字符设备驱动
  4. 调试常见问题的方法

扩展思考

  • 如何实现多个设备各自独立的缓冲区?
    可以在open函数中根据iminor(inode)分配不同的缓冲区,并将其地址保存在filp->private_data中,后续的read/write即可操作对应缓冲区。
  • 如何支持更复杂的 ioctl 命令?
    file_operations中添加.unlocked_ioctl成员,实现命令解析。
  • 如何在驱动中使用并发控制(信号量、自旋锁)?
    当多个进程同时访问驱动时,需要保护共享资源(如hello_buf),防止数据混乱。

字符设备驱动是 Linux 驱动开发的基石,扎实掌握这部分知识,将为你后续学习平台总线驱动、I2C/SPI 等子系统驱动打下坚实基础。


自测题(附答案)

1. 请解释struct cdev中的ops成员的作用。

答案ops是一个指向struct file_operations的指针,它将设备号与具体的操作函数(如openreadwrite)关联起来。当用户程序打开设备时,内核根据设备号找到对应的cdev,然后通过cdev->ops调用驱动实现的函数。

2.alloc_chrdev_region(&dev, 0, 2, "hello")中,参数02分别代表什么?

答案0表示起始次设备号(baseminor),2表示申请 2 个连续的次设备号(即 minor 0 和 minor 1)。

3. 下面的卸载代码有何问题?

c

unregister_chrdev_region(dev, 2); cdev_del(&hello_cdev);

答案:应该先调用cdev_del,再释放设备号。如果先释放设备号,其他驱动可能立刻占用同一设备号,而此时cdev尚未从内核移除,会导致设备号冲突和内核状态不一致。

4. 用户程序调用read(fd, buf, 100)时,内核如何一步步调用到驱动的hello_read函数?

答案

  1. 系统调用read进入 VFS(虚拟文件系统)。
  2. VFS 从fd对应的struct file中获取f_op(即hello_drv)。
  3. 调用f_op->read,即驱动的hello_read函数。
  4. 驱动执行copy_to_user将数据返回用户空间。

5. 为什么现代驱动推荐使用 cdev 接口而非register_chrdev

答案

  • 资源利用register_chrdev固定占用 256 个次设备号,而 cdev 接口按需申请,节省资源。
  • 灵活性:cdev 接口将设备号申请和 fops 绑定分离,一个驱动可注册多个 cdev 管理不同设备。
  • 可维护性:符合 Linux 设备模型的分层思想,代码更清晰。

6. 如何让本驱动支持两个独立的设备节点/dev/hello0/dev/hello1,且拥有各自的缓冲区?

答案

  1. 将全局hello_buf改为数组char hello_buf[2][100]
  2. open函数中通过iminor(inode)获得次设备号,保存到filp->private_data
  3. read/write中从filp->private_data取出次设备号,操作对应的缓冲区。
  4. hello_init中分别用device_create创建两个设备节点:hello0(minor 0)和hello1(minor 1)。

7. 加载模块时出现insmod: ERROR: could not insert module hello_drv.ko: File exists,可能的原因有哪些?如何解决?

答案:可能原因:

  • 模块已经在内存中(lsmod检查,rmmod卸载)。
  • 设备节点/dev/hello已存在(手动删除)。
  • 设备号在/proc/devices中残留(重启或完善卸载函数)。
    解决方法:依次执行rmmodrm -f /dev/hello、检查/proc/devices,必要时重启。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/16 20:49:16

今天吃什么?基于ModelEngine Nexent搭建的多模态饮食小助手

随着大模型平台的普及&#xff0c;智能体&#xff08;Agent&#xff09;开发正变得越来越直觉化。本文将带你完整体验如何利用 ModelEngine 平台&#xff0c;从零构建一个具备多模态能力的“AI 饮食健康助手”——它不仅能通过一张照片秒看卡路里&#xff0c;还能结合实时 MCP …

作者头像 李华
网站建设 2026/4/16 20:47:34

【技术干货】Super Gemma 4 26B:本地 AI Agent 开发的最佳实践方案

摘要 本文深度解析 Super Gemma 4 26B 无审查版模型在本地 Agent 工作流中的技术优势&#xff0c;涵盖 MoE 架构原理、MLX/GGUF 部署方案、Hermes Agent 集成实战&#xff0c;并提供完整的 Python 调用示例&#xff0c;助力开发者构建高性能本地 AI 应用。一、技术背景&#xf…

作者头像 李华
网站建设 2026/4/16 20:46:21

从I2C到SMBus:搞懂新版Spec 3.3,别再傻傻分不清了(附对比表格)

从I2C到SMBus&#xff1a;搞懂新版Spec 3.3&#xff0c;别再傻傻分不清了&#xff08;附对比表格&#xff09; 在嵌入式系统和硬件设计领域&#xff0c;I2C和SMBus这两种看似相似却又各具特色的总线协议常常让工程师们陷入选择困境。特别是在电源管理、温度监控等关键系统中&am…

作者头像 李华