2. 字符设备驱动基础:file_operations结构体、设备号注册与注销、cdev_init与cdev_add、简单的字符设备驱动示例
字符设备驱动是Linux驱动开发中最基础、最核心的模块之一。无论是GPIO控制、串口通信,还是后续将要学习的V4L2摄像头驱动,其底层都依赖于字符设备驱动框架。本章将深入剖析字符设备驱动的四大支柱:file_operations结构体、设备号管理、cdev初始化与添加,并通过一个完整的示例串联所有知识点。
2.1 file_operations结构体:驱动与用户空间的桥梁
file_operations结构体定义在<linux/fs.h>中,是驱动开发者向VFS(虚拟文件系统)注册的“操作函数表”。用户空间对设备文件执行open()、read()、write()、ioctl()等系统调用时,VFS通过该结构体找到驱动对应的处理函数。
核心概念: file_operations是一个函数指针集合,驱动只需实现需要的接口,未实现的指针置为NULL,VFS会返回对应错误码。
| 成员名称 | 函数原型 | 触发时机 | 典型用途 |
|---|---|---|---|
owner |
struct module *owner |
模块引用计数 | 通常设为THIS_MODULE |
open |
int (*open)(struct inode *, struct file *) |
用户调用open() |
设备初始化、权限检查 |
release |
int (*release)(struct inode *, struct file *) |
用户调用close() |
资源释放、计数器递减 |
read |
ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) |
用户调用read() |
从内核拷贝数据到用户空间 |
write |
ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) |
用户调用write() |
从用户空间接收数据到内核 |
unlocked_ioctl |
long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) |
用户调用ioctl() |
设备控制命令(V4L2大量使用) |
mmap |
int (*mmap)(struct file *, struct vm_area_struct *) |
用户调用mmap() |
内存映射(摄像头帧缓冲) |
poll |
__poll_t (*poll)(struct file *, struct poll_table_struct *) |
用户调用poll()/select() |
非阻塞I/O事件通知 |
V4L2关联: 摄像头驱动中,
unlocked_ioctl是核心接口,用于处理VIDIOC_QUERYCAP、VIDIOC_S_FMT等控制命令;mmap用于将视频帧缓冲映射到用户空间;poll用于等待帧数据就绪。
2.2 设备号:主设备号与次设备号
Linux内核使用设备号唯一标识一个设备驱动。设备号是一个dev_t类型(32位无符号整数),其中高12位为主设备号,低20位为次设备号。
- 主设备号:标识设备对应的驱动程序。内核通过主设备号找到
cdev或file_operations。 - 次设备号:由驱动内部使用,用于区分同类型的不同设备实例(如摄像头0、摄像头1)。
避坑提示: 主设备号是全局资源,静态分配需从
Documentation/admin-guide/devices.txt中选取未使用的号码。动态分配(推荐)使用alloc_chrdev_region(),避免冲突。
2.3 设备号注册与注销
内核提供两组API用于设备号管理:静态注册(已知主设备号)和动态分配(由内核自动分配)。
| 函数 | 原型 | 说明 |
|---|---|---|
register_chrdev_region |
int register_chrdev_region(dev_t from, unsigned count, const char *name) |
静态注册:指定起始设备号,适用于已知主设备号场景 |
alloc_chrdev_region |
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name) |
动态分配:内核自动分配主设备号,推荐使用 |
unregister_chrdev_region |
void unregister_chrdev_region(dev_t from, unsigned count) |
注销设备号,在模块卸载时调用 |
最佳实践: 始终优先使用
alloc_chrdev_region()动态分配主设备号。通过cat /proc/devices可以查看当前系统已分配的设备号。
2.4 cdev_init与cdev_add:将驱动注册到内核
cdev结构体是内核中字符设备的抽象。驱动需要完成以下步骤才能让设备文件与驱动关联:
- 分配cdev:静态定义或
kmalloc动态分配。 - 初始化cdev:调用
cdev_init(),绑定file_operations。 - 添加cdev:调用
cdev_add(),将cdev注册到内核,关联设备号。
2.5 完整的字符设备驱动示例
下面是一个完整的“虚拟内存设备”驱动示例。该驱动在内存中维护一个缓冲区,用户可以通过read/write读写该缓冲区。代码展示了本章所有核心概念的综合运用。
2.5.1 驱动代码:chardev_demo.c
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "chardev_demo"
#define BUFFER_SIZE 1024
static dev_t dev_num; // 设备号
static struct cdev *chardev_cdev; // cdev对象
static char *kernel_buffer; // 内核缓冲区
// open操作
static int chardev_open(struct inode *inode, struct file *file)
{
pr_info("chardev: device opened\n");
return 0;
}
// release操作
static int chardev_release(struct inode *inode, struct file *file)
{
pr_info("chardev: device closed\n");
return 0;
}
// read操作:从内核缓冲区拷贝数据到用户空间
static ssize_t chardev_read(struct file *file, char __user *buf,
size_t count, loff_t *ppos)
{
size_t available = BUFFER_SIZE - *ppos;
size_t to_read = count < available ? count : available;
if (to_read == 0)
return 0;
if (copy_to_user(buf, kernel_buffer + *ppos, to_read))
return -EFAULT;
*ppos += to_read;
pr_info("chardev: read %zu bytes from offset %lld\n", to_read, *ppos);
return to_read;
}
// write操作:从用户空间接收数据到内核缓冲区
static ssize_t chardev_write(struct file *file, const char __user *buf,
size_t count, loff_t *ppos)
{
size_t available = BUFFER_SIZE - *ppos;
size_t to_write = count < available ? count : available;
if (to_write == 0)
return -ENOSPC;
if (copy_from_user(kernel_buffer + *ppos, buf, to_write))
return -EFAULT;
*ppos += to_write;
pr_info("chardev: wrote %zu bytes at offset %lld\n", to_write, *ppos);
return to_write;
}
// file_operations结构体
static struct file_operations chardev_fops = {
.owner = THIS_MODULE,
.open = chardev_open,
.release = chardev_release,
.read = chardev_read,
.write = chardev_write,
};
// 模块初始化
static int __init chardev_init(void)
{
int ret;
// 1. 动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret < 0) {
pr_err("chardev: failed to allocate device number\n");
return ret;
}
pr_info("chardev: allocated major=%d, minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
// 2. 分配cdev
chardev_cdev = cdev_alloc();
if (!chardev_cdev) {
pr_err("chardev: failed to allocate cdev\n");
unregister_chrdev_region(dev_num, 1);
return -ENOMEM;
}
// 3. 初始化cdev并绑定fops
cdev_init(chardev_cdev, &chardev_fops);
chardev_cdev->owner = THIS_MODULE;
// 4. 添加cdev到内核
ret = cdev_add(chardev_cdev, dev_num, 1);
if (ret < 0) {
pr_err("chardev: failed to add cdev\n");
cdev_del(chardev_cdev);
unregister_chrdev_region(dev_num, 1);
return ret;
}
// 5. 分配内核缓冲区
kernel_buffer = kmalloc(BUFFER_SIZE, GFP_KERNEL);
if (!kernel_buffer) {
pr_err("chardev: failed to allocate buffer\n");
cdev_del(chardev_cdev);
unregister_chrdev_region(dev_num, 1);
return -ENOMEM;
}
memset(kernel_buffer, 0, BUFFER_SIZE);
pr_info("chardev: module loaded successfully\n");
return 0;
}
// 模块卸载
static void __exit chardev_exit(void)
{
kfree(kernel_buffer);
cdev_del(chardev_cdev);
unregister_chrdev_region(dev_num, 1);
pr_info("chardev: module unloaded\n");
}
module_init(chardev_init);
module_exit(chardev_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("V4L2 Course Author");
MODULE_DESCRIPTION("Simple Character Device Driver Demo");
2.5.2 Makefile
obj-m := chardev_demo.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
2.5.3 测试步骤
- 编译模块:执行
make,生成chardev_demo.ko。 - 加载模块:
sudo insmod chardev_demo.ko,查看dmesg确认主设备号。 - 创建设备文件:
sudo mknod /dev/chardev_demo c MAJOR 0(替换MAJOR为实际值)。 - 测试读写:
echo "Hello V4L2" > /dev/chardev_democat /dev/chardev_demo
- 卸载模块:
sudo rmmod chardev_demo,删除设备文件。
调试技巧: 使用
cat /proc/devices | grep chardev查看设备号分配情况;使用strace跟踪用户态系统调用,观察驱动接口调用顺序。
2.6 本章知识结构总结
| 知识点 | 核心函数/结构体 | 关键作用 | V4L2关联 |
|---|---|---|---|
| file_operations | struct file_operations |
定义驱动操作接口 | V4L2驱动注册video_device时绑定 |
| 设备号分配 | alloc_chrdev_region() |
动态获取主设备号 | video_register_device内部调用 |
| cdev初始化 | cdev_init() |
绑定fops到cdev | V4L2框架封装在video_device中 |
| cdev注册 | cdev_add() |
将设备加入内核 | video_register_device内部调用 |
| 数据拷贝 | copy_to_user/copy_from_user |
内核-用户空间数据交换 | 视频帧数据传递 |
| 模块生命周期 | module_init/module_exit |
驱动加载/卸载入口 | 所有驱动必备 |
本章要点: 字符设备驱动的核心是“设备号 + cdev + file_operations”三位一体。设备号是内核查找驱动的“门牌号”,cdev是驱动在内核中的“登记表”,file_operations是用户空间与硬件交互的“操作手册”。掌握这三者的关系,就掌握了Linux驱动开发的基石。后续V4L2框架的所有复杂机制,都是在这个基础之上构建的。