2. 字符设备驱动基础概念
字符设备驱动是嵌入式Linux驱动开发中最基础、最常用的驱动类型。理解其核心概念——设备号、file_operations结构体和cdev结构体——是编写任何字符设备驱动的第一步。本章将深入解析这三个要素,并通过SVG结构图、表格和代码示例,帮助你建立清晰的认知模型。
2.1 主设备号与次设备号
在Linux系统中,每个设备文件(如/dev/ttyS0)都对应一个设备号,用于唯一标识设备。设备号是一个32位无符号整数,由两部分组成:主设备号(major number)和次设备号(minor number)。
核心概念:主设备号标识设备驱动程序,次设备号标识由该驱动管理的具体设备实例。
2.1.1 设备号的数据结构与宏
Linux内核使用dev_t类型(定义在<linux/types.h>)来存储设备号。内核提供了以下宏来操作设备号:
| 宏/函数 | 功能 | 示例 |
|---|---|---|
MAJOR(dev) |
从dev_t中提取主设备号 |
MAJOR(dev_id) |
MINOR(dev) |
从dev_t中提取次设备号 |
MINOR(dev_id) |
MKDEV(major, minor) |
将主、次设备号组合成dev_t |
MKDEV(240, 0) |
提示:在32位系统中,dev_t的高12位用于主设备号(0~4095),低20位用于次设备号(0~1048575)。实际开发中,主设备号通常由内核动态分配,避免与现有设备冲突。
2.1.2 设备号分配方式
设备号分配有两种方式:静态分配和动态分配。推荐使用动态分配,以避免主设备号冲突。
- 静态分配:调用
register_chrdev_region(dev_t first, unsigned int count, const char *name),需要预先知道可用的主设备号。 - 动态分配:调用
alloc_chrdev_region(dev_t *dev, unsigned int baseminor, unsigned int count, const char *name),由内核自动分配未使用的主设备号。
// 动态分配设备号示例
dev_t dev_num;
int ret;
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_char_dev");
if (ret < 0) {
printk(KERN_ERR "Failed to allocate device number\n");
return ret;
}
printk(KERN_INFO "Allocated major=%d, minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
2.1.3 设备号与设备文件的关系
下图展示了设备号如何将用户空间的设备文件与内核空间的驱动程序关联起来:
2.2 file_operations结构体
file_operations结构体是字符设备驱动的核心接口,它定义了用户空间程序通过系统调用(如open、read、write)与设备交互时的回调函数指针。驱动开发者通过填充该结构体,将系统调用映射到具体的硬件操作。
2.2.1 结构体定义与关键成员
该结构体定义在<linux/fs.h>中,包含大量函数指针。以下是嵌入式开发中最常用的成员:
| 成员 | 系统调用 | 功能描述 |
|---|---|---|
owner |
— | 指向拥有该结构的模块,通常设为THIS_MODULE |
open |
open() |
打开设备,进行初始化操作 |
release |
close() |
关闭设备,释放资源 |
read |
read() |
从设备读取数据 |
write |
write() |
向设备写入数据 |
unlocked_ioctl |
ioctl() |
设备控制命令(无大内核锁) |
llseek |
lseek() |
改变文件读写位置 |
2.2.2 典型实现示例
以下代码展示了一个简单的字符设备驱动中file_operations的填充方式:
// 定义操作函数
static int my_open(struct inode *inode, struct file *filp) {
printk(KERN_INFO "Device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp) {
printk(KERN_INFO "Device closed\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t len, loff_t *off) {
// 实现读取逻辑
return 0;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t len, loff_t *off) {
// 实现写入逻辑
return len;
}
// 填充 file_operations 结构体
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
注意:在ARM嵌入式系统中,read和write回调函数中的buf参数指向用户空间缓冲区,不能直接在内核空间解引用。必须使用copy_to_user()和copy_from_user()函数进行安全的数据拷贝。
2.2.3 系统调用到驱动的完整流程
下图展示了用户调用read()系统调用后,内核如何通过file_operations找到驱动中的对应函数:
2.3 cdev结构体解析
cdev结构体是内核中用于表示字符设备的核心数据结构。它封装了设备号、file_operations以及设备链表信息,是驱动注册到内核的桥梁。
2.3.1 cdev结构体定义
cdev结构体定义在<linux/cdev.h>中,其关键成员如下:
| 成员 | 类型 | 说明 |
|---|---|---|
kobj |
struct kobject |
内核对象基类,用于引用计数和sysfs管理 |
owner |
struct module * |
所属模块指针,通常设为THIS_MODULE |
ops |
const struct file_operations * |
指向设备操作函数集的指针 |
list |
struct list_head |
用于将cdev加入内核设备链表 |
dev |
dev_t |
设备的起始设备号 |
count |
unsigned int |
该设备占用的次设备号数量 |
2.3.2 cdev的初始化与注册
使用cdev的典型步骤包括:分配、初始化、添加(注册)到内核。以下是标准流程:
- 分配cdev:使用
cdev_alloc()动态分配,或静态定义后使用cdev_init()初始化。 - 设置ops:将
file_operations结构体赋值给cdev->ops。 - 设置owner:将
cdev->owner设为THIS_MODULE。 - 注册cdev:调用
cdev_add()将设备加入内核。
// cdev 初始化与注册完整示例
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/module.h>
static dev_t dev_num;
static struct cdev my_cdev;
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
static int __init my_driver_init(void) {
int ret;
// 1. 动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
printk(KERN_ERR "alloc_chrdev_region failed\n");
return ret;
}
// 2. 初始化 cdev 结构体
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 将 cdev 注册到内核
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
printk(KERN_ERR "cdev_add failed\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
printk(KERN_INFO "Driver loaded: major=%d\n", MAJOR(dev_num));
return 0;
}
static void __exit my_driver_exit(void) {
// 4. 卸载时删除 cdev 并释放设备号
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "Driver unloaded\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
MODULE_LICENSE("GPL");
2.3.3 cdev、设备号与file_operations的关系
下图展示了cdev如何将设备号与file_operations绑定在一起,形成一个完整的字符设备驱动实体:
2.4 三者的协同工作流程
在字符设备驱动中,设备号、file_operations和cdev三者协同工作,构成了驱动注册和用户访问的完整链路。以下是标准流程总结:
| 步骤 | 操作 | 涉及要素 | 说明 |
|---|---|---|---|
| 1 | 分配设备号 | dev_t |
使用alloc_chrdev_region()或register_chrdev_region() |
| 2 | 初始化cdev | cdev + file_operations |
调用cdev_init(),绑定ops和owner |
| 3 | 注册cdev | cdev + dev_t |
调用cdev_add(),将设备加入内核链表 |
| 4 | 创建设备文件 | 设备号 | 使用mknod或udev自动创建/dev/xxx |
| 5 | 用户访问 | file_operations |
系统调用通过VFS找到fops中的回调函数 |
关键总结:
- 设备号是驱动与设备文件之间的“身份证”。
- file_operations是驱动功能的“函数菜单”,定义了驱动能做什么。
- cdev是驱动在内核中的“注册凭证”,将设备号和函数菜单打包提交给内核。
2.5 常见错误与调试建议
常见错误1:cdev_add失败
原因:设备号已被占用,或cdev_init未正确设置ops。检查dmesg输出,确认主设备号是否冲突。
常见错误2:用户空间访问时返回-ENODEV
原因:设备文件的主设备号与驱动注册的主设备号不匹配。使用cat /proc/devices查看已注册的设备号,确保mknod时使用正确的号码。
调试技巧:在驱动初始化函数中打印设备号信息,使用printk(KERN_INFO "Major: %d, Minor: %d\n", MAJOR(dev), MINOR(dev));,然后通过dmesg查看输出,快速定位问题。