3. 字符设备驱动入门
字符设备是Linux驱动开发中最基础、也最容易建立完整认知的一类设备。它按字节流方式访问,不经过块层缓存,典型代表有串口、按键、LED、GPIO扩展芯片以及各类传感器。要写出一个可加载、可访问、可卸载的字符设备驱动,必须掌握四个核心要素:设备号、file_operations、cdev以及文件操作接口的实现。
一、主次设备号:内核识别设备的“身份证”
在Linux中,每个字符设备都由一个设备号唯一标识。设备号是32位无符号整数,由主设备号和次设备号两部分组成。主设备号用于匹配对应的驱动程序,次设备号用于区分同一驱动下的不同设备实例。
# 查看当前系统中已注册的字符设备
cat /proc/devices
# 查看设备节点的主次设备号
ls -l /dev/ttyS0
crw-rw---- 1 root dialout 4, 64 ... /dev/ttyS0
# 其中 4 是主设备号,64 是次设备号
内核提供了一组宏来操作设备号,避免手工移位出错:
MKDEV(major, minor):由主、次设备号合成完整dev_t。MAJOR(dev):从dev_t中提取主设备号。MINOR(dev):从dev_t中提取次设备号。
设备号的分配有两种方式:一是静态指定,使用register_chrdev_region(),需要开发者自己选定一个未被占用的主设备号;二是动态分配,使用alloc_chrdev_region(),由内核自动分配可用主设备号,这是推荐做法,可避免冲突。释放时统一使用unregister_chrdev_region()。
二、file_operations:驱动与VFS之间的契约
用户空间对设备文件调用open/read/write/close时,VFS会把这些系统调用转发到驱动注册的file_operations结构体中对应的函数指针。因此,file_operations是字符设备驱动的核心接口表,它决定了驱动“能做什么”。
| 成员函数 | 对应系统调用 | 作用 |
|---|---|---|
| open | open() | 打开设备,完成初始化、引用计数递增 |
| release | close() | 关闭设备,释放资源、引用计数递减 |
| read | read() | 从设备读取数据到用户缓冲区 |
| write | write() | 将用户缓冲区数据写入设备 |
| unlocked_ioctl | ioctl() | 执行设备专属控制命令 |
| llseek | lseek() | 调整文件读写位置 |
| poll | poll()/select() | 支持多路复用与阻塞等待 |
未实现的成员应保持为NULL,VFS会返回-EINVAL或-ENOTTY等错误,而不是让内核崩溃。结构体通常用指定初始化器赋值,清晰且不易受字段顺序变化影响:
static const struct file_operations demo_fops = {
.owner = THIS_MODULE,
.open = demo_open,
.release = demo_release,
.read = demo_read,
.write = demo_write,
};
三、cdev的注册与注销:把驱动挂到内核
struct cdev是内核对字符设备的抽象,它把设备号与file_operations绑定在一起。注册流程通常分为三步:分配设备号、初始化并添加cdev、创建设备节点。
- 使用
alloc_chrdev_region()申请设备号范围。 - 用
cdev_init()初始化cdev并绑定fops,再调用cdev_add()将其加入内核。 - 通过
class_create()与device_create()自动生成/dev节点。
static int __init demo_init(void)
{
int ret;
dev_t devno;
ret = alloc_chrdev_region(&devno, 0, 1, "demo_chrdev");
if (ret < 0)
return ret;
cdev_init(&demo_cdev, &demo_fops);
demo_cdev.owner = THIS_MODULE;
ret = cdev_add(&demo_cdev, devno, 1);
if (ret < 0) {
unregister_chrdev_region(devno, 1);
return ret;
}
demo_class = class_create(THIS_MODULE, "demo");
device_create(demo_class, NULL, devno, NULL, "demo0");
return 0;
}
注销顺序与注册严格相反:先device_destroy()、class_destroy(),再cdev_del(),最后unregister_chrdev_region()。顺序颠倒会导致内核引用已释放对象,引发Oops。
四、open/read/write/release的实现要点
这四个接口是驱动与用户交互的主通道,实现时需注意参数语义与数据拷贝安全。
- open:参数为
struct inode *和struct file *。可通过container_of()从inode的cdev反查自定义设备结构体,完成硬件初始化或引用计数递增。 - read:原型为
ssize_t (*read)(struct file *, char __user *, size_t, loff_t *)。必须使用copy_to_user(),不能直接解引用用户指针;返回实际读取字节数。 - write:使用
copy_from_user()把用户数据搬到内核缓冲区,注意校验返回值,失败时返回-EFAULT。 - release:与open配对,释放缓冲区、关闭硬件、递减引用计数,返回值通常为0。
static ssize_t demo_read(struct file *filp, char __user *buf,
size_t count, loff_t *ppos)
{
if (*ppos >= sizeof(kbuf))
return 0;
if (count > sizeof(kbuf) - *ppos)
count = sizeof(kbuf) - *ppos;
if (copy_to_user(buf, kbuf + *ppos, count))
return -EFAULT;
*ppos += count;
return count;
}
五、完整数据流与状态关系
下图展示了从用户空间系统调用到驱动回调的完整路径,以及设备号、cdev、file_operations三者之间的绑定关系。
理解这张关系图后,编写字符设备驱动的思路就非常清晰:先确定设备号分配策略,再定义file_operations并实现四个基本回调,接着用cdev把设备号与fops绑定注册,最后在模块退出时按相反顺序注销。掌握这一套骨架,后续的ioctl、poll、并发控制与设备树匹配,都是在这个骨架上逐步扩展。