2. Linux 字符设备驱动框架:从结构体到模块加载
字符设备驱动,说白了就是 Linux 世界里最基础、最常用的一种驱动模型。你想想看,我们日常用的鼠标、键盘、串口,甚至编码器,本质上都是字符设备。它们的特点是数据按字节流传输,像水管里的水一样,一个一个字节地流过去。
我刚开始接触驱动开发时,总觉得这框架很抽象。后来亲手调了几个编码器驱动,才真正理解——这套框架其实就是内核和你的硬件之间的一纸契约。今天我们就来拆解这份契约。
2.1 file_operations:驱动与应用的接口契约
file_operations 结构体,是驱动开发者必须面对的第一个核心概念。它定义了用户程序通过系统调用(如 open、read、write)访问设备时,内核该调用你写的哪个函数。
核心理解: file_operations 就是一张函数跳转表。用户调用 read(),内核查这张表,找到你注册的 xxx_read() 函数,然后执行。
来看一个典型的编码器驱动中,我常用的 file_operations 定义:
static int encoder_open(struct inode *inode, struct file *filp)
{
// 我习惯在这里使能编码器硬件时钟
// 并初始化硬件寄存器
return 0;
}
static int encoder_release(struct inode *inode, struct file *filp)
{
// 关闭时释放资源,关闭时钟
return 0;
}
static ssize_t encoder_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
// 读取编码器当前计数值
// 注意要用 copy_to_user() 把数据从内核拷贝到用户空间
return 0;
}
static ssize_t encoder_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos)
{
// 写入控制参数,比如清零、设置预装载值
return 0;
}
static long encoder_ioctl(struct file *filp, unsigned int cmd,
unsigned long arg)
{
// 我经常用 ioctl 实现特殊控制,比如复位、配置模式
return 0;
}
static struct file_operations encoder_fops = {
.owner = THIS_MODULE,
.open = encoder_open,
.release = encoder_release,
.read = encoder_read,
.write = encoder_write,
.unlocked_ioctl = encoder_ioctl,
};
我的经验: 早期我总忘记设置 .owner 字段。虽然不设也能跑,但模块计数会出问题,rmmod 时可能崩溃。后来我养成了习惯——定义 fops 时第一个就写 .owner = THIS_MODULE。
2.2 设备号分配:主设备号与次设备号
每个字符设备在内核里都有一个唯一的编号——设备号。它由两部分组成:
| 组成部分 | 位数 | 作用 |
|---|---|---|
| 主设备号 | 12 位 | 标识设备对应的驱动程序 |
| 次设备号 | 20 位 | 标识同一个驱动管理的不同设备实例 |
分配设备号有两种方式:
- 静态分配:手动指定一个主设备号。适合你知道这个号没被占用的场景。
- 动态分配:让内核自动分配。我强烈推荐这种方式,省心。
代码示例:
#define ENCODER_MAJOR 0 // 0 表示动态分配
#define ENCODER_MINOR 0
#define ENCODER_CNT 1 // 只创建一个设备节点
dev_t dev_num;
if (ENCODER_MAJOR) {
// 静态分配
dev_num = MKDEV(ENCODER_MAJOR, ENCODER_MINOR);
ret = register_chrdev_region(dev_num, ENCODER_CNT, "encoder");
} else {
// 动态分配 —— 我几乎只用这种方式
ret = alloc_chrdev_region(&dev_num, ENCODER_MINOR, ENCODER_CNT, "encoder");
printk("Allocated dev number: major=%d, minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
}
我曾经踩过的坑: 有一次我用了静态分配的主设备号 200,结果和另一个模块冲突了。内核直接报错,设备注册失败。从那以后,我所有产品代码都改用动态分配。除非你有特殊理由,否则别用静态。
2.3 cdev 注册与注销:让设备活起来
设备号有了,file_operations 也定义好了,接下来就是把它们绑定到一起,注册到内核里。这一步靠的是 cdev 结构体。
注册流程我总结为三步:
- 初始化 cdev:调用 cdev_init(),绑定 file_operations
- 添加 cdev:调用 cdev_add(),告诉内核这个设备存在
- 创建设备节点:用 mknod 或 udev 自动创建 /dev/encoder0
代码实现:
struct cdev encoder_cdev;
// 第一步:初始化
cdev_init(&encoder_cdev, &encoder_fops);
encoder_cdev.owner = THIS_MODULE;
// 第二步:注册到内核
ret = cdev_add(&encoder_cdev, dev_num, 1);
if (ret) {
printk("Failed to add cdev\n");
goto err_cdev_add;
}
// 第三步:自动创建设备节点(配合 udev/mdev)
device_create(encoder_class, NULL, dev_num, NULL, "encoder%d", 0);
注销时顺序反过来:
device_destroy(encoder_class, dev_num);
cdev_del(&encoder_cdev);
unregister_chrdev_region(dev_num, ENCODER_CNT);
我的习惯: 我总在模块初始化函数里用 goto 做错误处理。每个 goto 标签对应一个清理步骤,这样出错时能逐级回滚,不会漏掉资源释放。你可以在后面的完整代码里看到这个模式。
2.4 模块加载与卸载:驱动的生命周期
驱动最终以模块形式存在。加载时调用 module_init() 指定的函数,卸载时调用 module_exit() 指定的函数。
来看一个完整的编码器驱动骨架:
static int __init encoder_init(void)
{
dev_t dev_num;
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, "encoder");
if (ret < 0) {
printk("Failed to alloc dev number\n");
return ret;
}
// 2. 创建类(用于自动创建设备节点)
encoder_class = class_create(THIS_MODULE, "encoder_class");
if (IS_ERR(encoder_class)) {
ret = PTR_ERR(encoder_class);
goto err_class;
}
// 3. 初始化并添加 cdev
cdev_init(&encoder_cdev, &encoder_fops);
encoder_cdev.owner = THIS_MODULE;
ret = cdev_add(&encoder_cdev, dev_num, 1);
if (ret) {
goto err_cdev_add;
}
// 4. 创建设备节点
device_create(encoder_class, NULL, dev_num, NULL, "encoder0");
printk("Encoder driver loaded, major=%d\n", MAJOR(dev_num));
return 0;
err_cdev_add:
class_destroy(encoder_class);
err_class:
unregister_chrdev_region(dev_num, 1);
return ret;
}
static void __exit encoder_exit(void)
{
dev_t dev_num = MKDEV(encoder_major, 0);
device_destroy(encoder_class, dev_num);
cdev_del(&encoder_cdev);
class_destroy(encoder_class);
unregister_chrdev_region(dev_num, 1);
printk("Encoder driver unloaded\n");
}
module_init(encoder_init);
module_exit(encoder_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Encoder character device driver");
2.5 知识体系总览
下面这张图把整个字符设备驱动框架串起来了。我建议你把它存下来,写驱动时对照着看:
2.6 调试小技巧
写驱动时,我常用这几个方法快速验证框架是否正确:
- 查看设备号:
cat /proc/devices | grep encoder,看主设备号是否注册成功 - 查看设备节点:
ls -l /dev/encoder*,确认节点存在且权限正确 - 测试 open/close:写一个简单的测试程序,只调用 open() 和 close(),看 dmesg 里有没有打印
- 测试 read/write:用 dd 命令或自己写测试程序,验证数据通路
核心要点回顾:
- file_operations 是驱动与应用的接口契约,必须实现 open/release,其他按需实现
- 设备号推荐动态分配,用 alloc_chrdev_region()
- cdev 注册顺序:init → add → device_create;注销顺序相反
- 模块加载/卸载函数中,用 goto 做错误处理,确保资源不泄漏
嗯,到这里字符设备驱动框架的核心内容就讲完了。这套框架我用了十年,从简单的 LED 驱动到复杂的编码器驱动,万变不离其宗。你只要把今天这几个结构体和流程吃透,后面写任何字符设备驱动都会很顺手。