第一章 字符设备驱动框架:从零开始理解内核与驱动的接口
大家好,我是你们的老朋友。今天咱们来聊聊字符设备驱动框架。说实话,这个主题我讲了不下几十次,但每次都有新感悟。字符设备驱动,说白了就是Linux内核里最简单、最基础的一种设备模型。你想想看,键盘、鼠标、串口,这些我们天天打交道的设备,本质上都是字符设备。
我记得刚入行那会儿,带我的师傅跟我说:「搞懂字符设备驱动,你就摸到了内核的大门。」当时我不信,后来做了几年车规级驱动开发,才发现这句话一点不假。很多复杂的驱动,底层逻辑都离不开这套框架。
1.1 file_operations结构体:驱动与用户态的桥梁
先问大家一个问题:用户程序怎么跟硬件打交道?答案是——通过文件。在Linux世界里,「一切皆文件」可不是说着玩的。用户程序调用open()、read()、write(),内核就会找到对应的驱动函数去执行。
这个对应关系,就靠file_operations结构体来维护。我习惯叫它「操作函数表」。你想想看,它就像一张菜单,用户点哪个菜(调用哪个系统调用),内核就去执行对应的函数。
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 *);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
// ... 还有很多
};
核心要点:你不需要实现所有函数。用不到的函数,直接设为NULL就行。内核会帮你处理默认行为。
我在项目中遇到过一件事:有个同事把owner字段忘了赋值,结果模块卸载时系统直接崩溃。嗯,这个坑我踩过一次就再也没忘过。owner一定要设为THIS_MODULE,这是告诉内核「这个驱动属于哪个模块」。
1.2 主次设备号:设备的身份证
每个字符设备在内核里都有个唯一标识——设备号。它分两部分:主设备号和次设备号。主设备号表示驱动类型,次设备号表示具体设备实例。
打个比方:主设备号是「小区门牌号」,次设备号是「具体房间号」。同一个驱动可以管理多个设备,靠次设备号来区分。
| 概念 | 作用 | 示例 |
|---|---|---|
| 主设备号 | 标识驱动类型 | 89(I2C设备) |
| 次设备号 | 标识具体设备 | 0、1、2... |
申请设备号有两种方式:
- 静态申请:指定一个主设备号。适合你知道哪个号没被占用的情况。
- 动态申请:让内核帮你分配。我建议新手都用这个,省心。
// 动态申请设备号
int alloc_chrdev_region(dev_t *dev, unsigned int firstminor,
unsigned int count, char *name);
// 静态申请设备号
int register_chrdev_region(dev_t from, unsigned int count,
const char *name);
我的习惯:开发阶段用动态分配,产品定型后可以改成静态。这样调试方便,也避免跟其他驱动冲突。
1.3 cdev结构体:内核里的设备对象
有了操作函数表,有了设备号,怎么把它们绑定到一起?这就轮到cdev结构体登场了。
cdev是内核里表示字符设备的对象。我把它理解成一个「包装盒」——你把file_operations和设备号装进去,然后注册到内核里。
struct cdev {
struct kobject kobj;
struct module *owner;
const struct file_operations *ops;
struct list_head list;
dev_t dev;
unsigned int count;
};
初始化cdev的步骤很简单:
- 用
cdev_alloc()分配内存,或者静态定义 - 用
cdev_init()绑定file_operations - 用
cdev_add()注册到内核
struct cdev my_cdev;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
cdev_add(&my_cdev, dev_num, 1);
注意:cdev_add()之后,设备就立即可用了。所以一定要确保所有初始化都完成了再调用它。我曾经因为顺序搞反,导致用户程序打开设备时读到未初始化的数据,查了好几天才找到原因。
1.4 设备节点的创建:mdev和udev
设备注册好了,用户程序怎么访问?需要通过设备节点——就是/dev目录下的那个文件。
传统做法是手动mknod,但现代系统都用自动创建。这里有两个主角:
- udev:用户空间设备管理器,主流桌面系统都用它
- mdev:udev的轻量版,常用于嵌入式系统
它们的工作原理其实很简单:内核在注册设备时会发送uevent,udev/mdev收到后,根据规则在/dev下创建节点。
要让自动创建生效,驱动里需要做一件事:创建一个设备类(class),然后在类下创建设备。
static struct class *my_class;
my_class = class_create(THIS_MODULE, "my_device_class");
device_create(my_class, NULL, dev_num, NULL, "my_device");
这样,/dev/my_device就会自动出现了。我在做车机项目时,用的就是mdev。嵌入式环境资源有限,mdev比udev轻量得多。
1.5 完整的字符设备驱动示例
光说不练假把式。咱们写一个完整的示例,把上面所有知识点串起来。
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "my_demo"
#define CLASS_NAME "demo_class"
static int major;
static struct class *demo_class;
static struct cdev demo_cdev;
static int demo_open(struct inode *inode, struct file *file)
{
pr_info("Device opened\n");
return 0;
}
static int demo_release(struct inode *inode, struct file *file)
{
pr_info("Device closed\n");
return 0;
}
static ssize_t demo_read(struct file *file, char __user *buf,
size_t len, loff_t *offset)
{
char kernel_buf[] = "Hello from kernel!\n";
if (*offset >= sizeof(kernel_buf))
return 0;
if (len > sizeof(kernel_buf) - *offset)
len = sizeof(kernel_buf) - *offset;
if (copy_to_user(buf, kernel_buf + *offset, len))
return -EFAULT;
*offset += len;
return len;
}
static struct file_operations demo_fops = {
.owner = THIS_MODULE,
.open = demo_open,
.release = demo_release,
.read = demo_read,
};
static int __init demo_init(void)
{
dev_t dev_num;
// 1. 动态分配设备号
alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
major = MAJOR(dev_num);
// 2. 初始化cdev
cdev_init(&demo_cdev, &demo_fops);
demo_cdev.owner = THIS_MODULE;
// 3. 注册cdev
cdev_add(&demo_cdev, dev_num, 1);
// 4. 创建设备类和设备节点
demo_class = class_create(THIS_MODULE, CLASS_NAME);
device_create(demo_class, NULL, dev_num, NULL, DEVICE_NAME);
pr_info("Demo driver loaded, major=%d\n", major);
return 0;
}
static void __exit demo_exit(void)
{
dev_t dev_num = MKDEV(major, 0);
// 反向清理
device_destroy(demo_class, dev_num);
class_destroy(demo_class);
cdev_del(&demo_cdev);
unregister_chrdev_region(dev_num, 1);
pr_info("Demo driver unloaded\n");
}
module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver demo");
关键点总结:
- 初始化顺序:设备号 → cdev → 设备类 → 设备节点
- 清理顺序:完全反过来
- copy_to_user/copy_from_user:用户空间和内核空间数据拷贝的专用函数
- THIS_MODULE:别忘了赋值给owner
加载这个模块后,你会在/dev下看到my_demo设备。用cat /dev/my_demo就能读到我们返回的字符串。
调试小技巧:用cat测试时,如果卡住了,多半是read函数里没有正确处理offset。记得检查返回值逻辑。
好了,第一章的内容就到这里。字符设备驱动框架,说白了就是三件事:定义操作函数、申请设备号、注册cdev。剩下的都是锦上添花。下一章咱们聊聊更深入的内容——并发与同步控制。到时候见!