当前位置:首页 > 技术学院 > 技术前线
[导读]字符设备是Linux中最基础的设备类型之一,键盘、鼠标、串口、I2C设备、LED驱动这类常用的嵌入式设备,大多都属于字符设备,掌握字符设备驱动的基本框架,是学习Linux驱动开发的第一步。Linux内核从早期的2.6版本到现在的6.x版本,字符设备驱动框架经过了多次演化,已经形成了一套成熟、规范的设计,把驱动开发中重复的通用逻辑都封装好了,开发者只需要实现硬件相关的操作即可。

字符设备是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字符设备驱动开始,按照框架一步步实现,跑通一次完整流程,就能很快掌握这个框架的核心逻辑。

本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除( 邮箱:macysun@21ic.com )。
换一批
延伸阅读

树莓派3B凭借低成本、高性能、丰富的外设资源,一直是嵌入式爱好者和开发者学习RTOS的热门平台,而RT-Thread作为国内生态最完善的开源实时操作系统,对树莓派3B有着完善的原生支持。但很多刚接触的开发者,往往卡在环境...

关键字: RT-Thread Linux

本节演示如何使用AIE DIALECTS和AIE API,在AMD Ryzen AI Phoenix中对复杂数字信号在频域进行“相位变换”。

关键字: Linux 相位变换 AI

在360环视系统的初始验证阶段,我们采用了一套直观且广泛使用的技术栈:OpenCV负责从采集到显示的全部图像处理任务。功能层面,这套方案完全跑通了——四路鱼眼去畸变、透视投影、鸟瞰拼接,所有算法逻辑均正确。但当我们将目光...

关键字: GPU Linux CPU

谈到Linux的最大并发数,很多开发者会本能想到系统配置里的ulimit -n,觉得改大这个值就能支持更多并发,甚至默认“Linux最大并发可以到几十万上百万”。但实际生产环境中,经常遇到明明把文件句柄数改到了10万,并...

关键字: Linux 并发数

当我们在代码里调用read读取文件,调用malloc分配内存,调用socket创建网络连接的时候,最终都会落到系统调用上。但很多开发者只知道系统调用是用户程序请求内核服务的接口,却说不清系统调用到底是怎么实现的:为什么用...

关键字: Linux 系统调用

在工业自动化现场,我们时常听到这样的抱怨:"明明 Linux 上跑个 EtherCAT 主站协议栈很简单,可一到多轴联动、精密组装这类场景,周期一不小心就'飘'了,轨迹抖得让人心慌。" 问题就出在"硬实时"三个字上。要在...

关键字: 工业自动化 Linux 半导体

在云原生技术蓬勃发展的今天,容器凭借轻量、高效、可移植的特性,成为构建现代应用的核心载体。然而,容器并非绝对安全的“隔离堡垒”——当内核存在漏洞时,攻击者可通过容器逃逸突破隔离限制,直接获取宿主机的控制权,进而威胁整个集...

关键字: 内核 Linux

在探讨Linux可执行文件如何装载进虚拟内存之前,我们首先需要理解虚拟内存这一核心概念。虚拟内存是计算机系统内存管理的一种关键技术,它为应用程序构建了一个看似连续完整的地址空间,让程序认为自己拥有一块独立且连续的内存区域...

关键字: Linux 虚拟内存

在软件开发过程中,调试是定位和解决问题的关键环节。GDB(GNU Debugger)作为Linux平台下最常用的调试工具,支持对C、C++等多种语言程序的调试,能够帮助开发者监控程序执行、检查变量值、定位崩溃原因。然而,...

关键字: GDB Linux

随着嵌入式Linux系统的复杂度不断增加,设备驱动开发面临着新的挑战。传统的内核编码方式已难以满足现代SoC平台硬件配置的灵活性和可维护性需求,而设备树(Device Tree)技术的引入,彻底改变了Linux内核与硬件...

关键字: Linux 驱动开发
关闭