Linux字符设备的基本概念
字符设备是Linux中最基础的设备类型之一,键盘、鼠标、串口、I2C设备、LED驱动这类常用的嵌入式设备,大多都属于字符设备,掌握字符设备驱动的基本框架,是学习Linux驱动开发的第一步。Linux内核从早期的2.6版本到现在的6.x版本,字符设备驱动框架经过了多次演化,已经形成了一套成熟、规范的设计,把驱动开发中重复的通用逻辑都封装好了,开发者只需要实现硬件相关的操作即可。今天我们就来梳理Linux字符设备驱动的基本框架,从核心数据结构到设备注册、文件操作的完整流程,帮大家理清字符设备驱动的核心脉络。
一、Linux字符设备的基本概念
首先我们要明确,Linux中把设备分为三大类:字符设备、块设备、网络设备,字符设备是最基础、最常用的一类。它的核心特点是以字节流为单位进行顺序读写,不需要像块设备那样按块缓存、随机访问,也不需要像网络设备那样处理数据包封装。简单来说,字符设备就是可以像读文件一样,一个字节一个字节读写的设备,打开之后可以直接读、直接写,不需要额外的块地址转换。
Linux内核有一个非常核心的设计思想:一切皆是文件,所有设备都对应文件系统中的一个节点,应用程序可以通过操作/dev目录下的设备节点,就像操作普通文件一样操作硬件设备,不需要修改应用程序的逻辑。字符设备也不例外,每个字符设备在内核中都有一个主设备号和次设备号,主设备号用来区分不同类型的设备,次设备号用来区分同类型的多个设备,内核通过主设备号找到对应的驱动,通过次设备号找到具体哪个设备。
比如我们常见的/dev/ttyS0串口设备,主设备号是4,次设备号是0,代表这是第0个串口设备;/dev/input/mouse0鼠标设备,主设备号是13,次设备号是32,内核通过主设备号就可以找到对应的串口驱动或者鼠标驱动,再通过次设备号定位具体设备。
二、核心数据结构:框架的骨架
Linux字符设备驱动框架的所有逻辑,都围绕几个核心结构体展开,理清这几个结构体,就已经理清了一半的框架脉络,我们来看最核心的几个结构:
1. struct cdev:字符设备的内核抽象
从2.6内核开始,Linux内核用struct cdev来描述一个字符设备,这个结构体定义在中,核心成员如下:
struct cdev {
struct kobject kobj; // 内嵌的内核对象,用于sysfs文件系统管理
struct module *owner; // 模块所属,一般填THIS_MODULE,用于模块引用计数
const struct file_operations *ops; // 设备的文件操作集合,就是驱动实现的open/read/write
struct list_head list; // 链表节点,内核把所有cdev串成链表管理
dev_t dev; // 设备号,保存了主设备号和次设备号
};
这个结构体是内核中字符设备的核心抽象,不管是什么类型的字符设备,都需要用这个结构来描述,它把设备号、文件操作、模块信息都绑定在了一起,内核通过它找到对应设备的操作函数。
2. struct file_operations:文件操作方法集合
struct file_operations是字符设备驱动最核心的部分,它是一个函数指针集合,对应应用层对设备文件的各种操作,应用层调用open,内核就会调用file_operations里的open函数;应用层调用read,内核就会调用里面的read函数,我们开发字符设备驱动,本质上就是实现这个结构体里需要的函数。
一个常用的file_operations定义大概是这样:
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
.unlocked_ioctl = my_ioctl,
.llseek = my_llseek,
};
里面每一个成员都是一个函数指针,我们不需要实现所有的函数,只需要实现用到的,没用的会默认设置成NULL,内核调用的时候会做默认处理。比如不需要llseek,就不定义,用户调用lseek的时候内核会直接返回错误。
这里要注意,现在内核都推荐使用unlocked_ioctl而不是旧的ioctl,因为旧的ioctl会持有大内核锁,性能不好,新的驱动都应该用unlocked_ioctl。
3. struct file:打开的文件描述符对应结构
应用程序每次open设备,内核都会创建一个struct file结构体,这个结构体保存了当前打开文件的状态信息,比如文件偏移位置f_pos、文件标志f_flags、私有数据指针private_data。我们驱动开发中常用的就是private_data,一般会在open的时候把我们自己定义的设备结构体指针赋值给file->private_data,之后在read、write、ioctl中就可以直接取出来用,非常方便。
4. dev_t:设备号的存储
dev_t是一个32位的整数,用来保存设备号,其中高12位是主设备号,低20位是次设备号,内核提供了几个宏来拆分和组合设备号:
// 从dev_t中取出主设备号
#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS))
// 从dev_t中取出次设备号
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK))
// 用主设备号和次设备号合成dev_t
#define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi))
我们申请设备号的时候,用MKDEV把主设备号和次设备号合成dev_t,传给内核函数就可以了。
三、字符设备驱动的基本流程:从模块初始化到设备注册
一个完整的字符设备驱动,从加载模块到卸载模块,基本流程是固定的,我们分步骤来看:
第一步:申请设备号
加载模块后,第一件事就是申请设备号,内核提供了两种申请方式:静态指定和动态申请。
静态指定就是我们自己选一个没有被内核使用的主设备号,直接申请,一般用register_chrdev_region函数:
int register_chrdev_region(dev_t from, unsigned int count, const char *name);
其中from是我们合成好的dev_t,count是要申请的连续次设备号数量,name是设备的名称,会出现在/proc/devices中。静态申请的好处是设备号固定,创建设备节点的时候不用查,适合固定设备的场景,但如果我们的主设备号和其他驱动冲突,加载就会失败,所以现在更推荐动态申请。
动态申请是让内核自动分配一个可用的主设备号,不用我们自己选,用alloc_chrdev_region函数:
int alloc_chrdev_region(dev_t *dev, unsigned int baseminor, unsigned int count, const char *name);
这个函数会把分配好的设备号存在dev指针指向的变量里,我们分配完之后可以用MAJOR宏拿到分配到的主设备号,打印出来,再创建设备节点,动态分配不会冲突,是现在推荐的用法。
第二步:初始化cdev结构
拿到设备号之后,我们需要初始化struct cdev结构体,用cdev_init函数:
void cdev_init(struct cdev *cdev, const struct file_operations *fops);
这个函数会把cdev初始化为默认状态,把我们的file_operations指针赋值进去,初始化链表,非常方便。一般我们会把cdev内嵌到我们自己定义的设备结构体中,比如:
struct my_device {
struct cdev cdev; // 内嵌cdev结构
int dev_minor; // 次设备号
void *hw_base; // 硬件寄存器基地址
/* 其他自定义的设备信息 */
};
这样管理多个同类型设备的时候,非常方便,每个设备都有自己的cdev和私有信息。
初始化完之后,我们设置一下cdev的owner和dev:
cdev.owner = THIS_MODULE;
cdev.dev = my_devt; // 我们申请到的设备号
第三步:向内核注册cdev
初始化完成之后,调用cdev_add把cdev注册到内核中,这一步完成之后,我们的字符设备就正式生效了,内核就可以找到这个设备,响应应用层的打开操作了:
int cdev_add(struct cdev *p, dev_t dev, unsigned int count);
这里要注意一个常见的错误:一定要先初始化硬件、申请好所有资源,再调用cdev_add,因为只要调用完cdev_add,设备就生效了,内核随时可能有应用程序打开访问,如果硬件还没初始化好,就会出现空指针或者非法访问错误。
第四步:创建设备节点
注册完cdev之后,我们还需要在/dev目录下创建设备节点,方便应用程序打开。现在大部分系统都用udev或者mdev自动创建设备节点,不需要我们手动用mknod创建,我们只需要在驱动中创建一个class,然后创建设备就可以了:
/* 创建一个class,会出现在sysfs中 */
struct class *my_class = class_create(THIS_MODULE, "my_dev_class");
/* 创建设备,udev会自动在/dev下创建设备节点 */
device_create(my_class, NULL, devt, NULL, "my_dev%d", minor);
device_create会把设备信息导出到sysfs,udev检测到新设备,就会自动在/dev目录下创建对应的设备节点,不需要我们手动操作,这是现在最常用的方法,比手动mknod方便很多。
到这里,模块初始化就完成了,加载模块之后,我们就能在/dev目录下看到我们的设备节点,应用程序就可以打开访问了。
卸载模块的流程
卸载模块的时候,按照相反的顺序做清理就可以了:
如果是用device_create创建了设备,先调用device_destroy销毁设备;
销毁之前创建的class,调用class_destroy;
调用cdev_del把cdev从内核中删除;
调用unregister_chrdev_region释放申请到的设备号;
释放我们申请的其他资源,比如内存、IO地址、中断等等。
整个流程是反过来的,先创建的后释放,后创建的先释放,避免出现野指针问题。
四、文件操作函数的实现框架
我们申请注册完设备,接下来就是实现file_operations里的各个操作函数,这是驱动和硬件打交道的核心部分,我们来看几个最常用函数的基本框架:
1. open函数
open函数是应用程序打开设备的时候第一个调用的函数,基本框架如下:
static int my_open(struct inode *inode, struct file *file)
{
struct my_device *dev;
/* 从inode的i_cdev中拿到我们的cdev,再拿到自定义设备结构体指针 */
dev = container_of(inode->i_cdev, struct my_device, cdev);
/* 把设备指针保存到file->private_data,方便后续函数使用 */
file->private_data = dev;
/* 这里做硬件初始化,比如打开硬件电源,初始化寄存器 */
return 0;
}
这里用container_of宏从内嵌的cdev指针拿到我们自定义设备结构体的指针,是非常常用的技巧,因为我们把cdev内嵌到自定义结构体里,通过这个宏就能快速拿到整个设备结构体。
2. release函数
release函数是应用程序调用close关闭设备的时候调用,做清理工作:
static int my_release(struct inode *inode, struct file *file)
{
struct my_device *dev = file->private_data;
/* 这里做清理,比如关闭硬件电源,释放打开的时候申请的临时资源 */
return 0;
}
每打开一次设备就会调用一次open,每关闭一次就调用一次release,所以不要把整个设备的初始化放在open里做多次申请,一般只有第一次打开才需要初始化硬件。
3. read函数
read函数是应用程序调用read从设备读数据的时候调用,基本框架:
static ssize_t my_read(struct file *file, char __user *buf, size_t count, loff_t *ppos)
{
struct my_device *dev = file->private_data;
int ret;
/* 从硬件寄存器中读出数据,放到内核缓冲区 */
/* 把数据从内核空间拷贝到用户空间,不能直接访问用户空间地址 */
ret = copy_to_user(buf, kernel_buf, read_len);
/* 更新文件偏移 */
*ppos += read_len;
/* 返回读到的字节数 */
return read_len;
}
这里有一个非常重要的点:buf指针是用户空间的地址,内核不能直接访问,必须用copy_to_user来拷贝数据,直接拷贝会触发内存错误,这是初学者最容易犯的错误。同样,写的时候从用户空间拿数据,要用copy_from_user。
4. write函数
write函数和read相反,是把用户空间的数据写到设备里:
static ssize_t my_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos)
{
struct my_device *dev = file->private_data;
int write_len = count;
/* 把数据从用户空间拷贝到内核空间 */
if (copy_from_user(kernel_buf, buf, write_len)) {
return -EFAULT;
}
/* 把数据写到硬件寄存器 */
/* 更新偏移 */
*ppos += write_len;
/* 返回写入的字节数 */
return write_len;
}
copy_from_user如果失败,会返回没有拷贝成功的字节数,我们返回-EFAULT告诉应用程序拷贝出错。
5. unlocked_ioctl函数
ioctl用来做除了读写之外的其他控制操作,比如调整波特率、设置LED亮度、启动设备停止设备,框架如下:
static long my_unlocked_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
struct my_device *dev = file->private_data;
/* 根据cmd判断是什么命令 */
switch (cmd) {
case MY_CMD_SET_BRIGHTNESS:
/* 设置亮度,arg就是亮度值,或者是用户空间传递参数的地址 */
set_hw_brightness(arg);
break;
case MY_CMD_GET_STATUS:
/* 把状态返回给用户空间 */
copy_to_user((void __user *)arg, &status, sizeof(status));
break;
default:
return -ENOTTY; // 不支持的命令,返回这个错误码
}
return 0;
}
ioctl非常灵活,几乎可以实现所有自定义控制功能,所以字符设备驱动里一般都少不了ioctl。
五、旧框架和新框架的对比:为什么用cdev分层
很多学过旧驱动的朋友会见过register_chrdev这个函数,这个函数是旧的框架,一次把整个主设备号注册了,不需要手动初始化cdev,代码看起来更简单:
/* 旧框架,直接注册字符设备 */
int register_chrdev(unsigned int major, const char *name, const struct file_operations *fops);
为什么现在不用这个方法,要用cdev_add这套分层框架呢?主要有两个原因:
旧框架register_chrdev一次占用整个主设备号下的所有次设备号(最多256个),哪怕你只用到一个次设备号,其他的也都被占用了,浪费设备号资源,新框架可以灵活申请任意数量的次设备号,不会浪费。
旧框架把设备号、cdev和操作都绑在一起,不支持一个主设备号下挂载不同的文件操作,也不方便管理多个自定义设备,新框架用cdev每个设备一个结构,更灵活,适合复杂场景。
所以现在新开发的字符设备驱动,都推荐用cdev_add这套新框架,register_chrdev只是为了兼容旧代码保留的,新代码不推荐使用。
六、字符设备驱动框架的设计思想总结
Linux字符设备驱动框架的设计,体现了Linux内核驱动设计一贯的思想: 第一,分层抽象:内核把设备管理、设备号分配、sysfs交互这些通用逻辑都封装好了,驱动开发者只需要实现具体的硬件操作,不需要重复写通用逻辑,降低了开发门槛。 第二,一切皆是文件:把设备抽象成文件,应用程序用统一的文件操作接口访问硬件,不用学习特殊的API,统一了访问方式,非常优雅。 第三,对扩展开放:框架只定义了接口,具体操作由驱动实现,不管是什么样的字符设备,都可以套进这个框架里,灵活性非常高。
当然,现在内核也有更高级的框架,比如misc驱动框架,其实就是对字符设备框架的进一步封装,把设备号申请、cdev注册、class创建这些流程都封装好了,对于简单的字符设备,只需要实现file_operations就可以了,本质上还是基于cdev字符设备框架做的封装。
七、常见的坑与调试技巧
初学者写字符设备驱动,经常遇到几个问题: 第一个问题是加载模块之后/dev目录下没有设备节点,一般是因为没有创建class,或者udev没有运行,检查一下/sys/class下面有没有对应的class,再检查dmesg有没有报错。 第二个问题是打开设备的时候提示“No such device or address”,一般是cdev_add注册失败,或者设备号不对,检查一下申请设备号有没有成功,主设备号有没有冲突。 第三个问题是读写的时候内核崩溃,一般是忘记用copy_to_user/copy_from_user,直接操作用户空间地址,或者忘记判断NULL指针,一定要记住用户空间地址不能直接在内核访问,必须用拷贝函数。
调试字符设备驱动最常用的方法就是用printk打印日志,加载模块、打开、读写的时候打印关键信息,看看流程走到哪一步出错,也可以用ls /dev/看设备节点有没有创建,cat /proc/devices看主设备号有没有注册成功,这些方法足够定位大部分常见问题。
Linux字符设备驱动框架是Linux驱动开发的基础,整个框架流程清晰,设计简洁,核心就是“申请设备号-初始化cdev-注册cdev-实现文件操作”这几步,掌握了这个基础框架,后续学习其他更复杂的驱动(比如平台驱动、混杂设备驱动)都会轻松很多。
现在虽然很多嵌入式驱动都可以用内核已经有的框架,不用从零写字符设备驱动,但理解字符设备驱动的基本框架,依然是每个Linux驱动开发者必须掌握的基础,它能帮我们理解Linux内核处理设备的基本思路,遇到问题的时候也能更快定位排查。对于初学者来说,从一个简单的LED字符设备驱动开始,按照框架一步步实现,跑通一次完整流程,就能很快掌握这个框架的核心逻辑。





