3. 字符设备驱动基础概念
字符设备驱动是 Linux 内核驱动中最基础、最常用的驱动类型,也是 Android 内核扩展与硬件抽象的核心实现方式。本章将系统讲解字符设备驱动的四个核心概念:设备文件、主设备号与次设备号、file_operations 结构体以及模块加载与卸载。理解这些概念是后续编写 ioctl 实战代码的基石。
学习提示:字符设备以字节流方式读写数据,不支持随机访问(如键盘、鼠标、串口、传感器)。与块设备(如硬盘)和网络设备(如网卡)相比,字符设备的驱动模型最为简洁,适合初学者入门。
3.1 设备文件
在 Linux 系统中,一切皆文件。硬件设备也通过设备文件(又称设备节点)暴露给用户空间。设备文件位于 /dev 目录下,用户程序通过标准的文件操作接口(open、read、write、ioctl、close)与设备通信,无需关心底层硬件细节。
关键理解:设备文件是用户空间与内核驱动之间的桥梁。用户程序操作设备文件时,VFS(虚拟文件系统)根据设备文件记录的设备号,找到对应的内核驱动,并调用驱动注册的操作函数。
设备文件有两种类型:
| 类型 | 标识 | 特点 | 示例 |
|---|---|---|---|
| 字符设备文件 | c |
以字节流方式读写,顺序访问 | /dev/ttyS0、/dev/input/event0 |
| 块设备文件 | b |
以块为单位读写,支持随机访问 | /dev/sda、/dev/mmcblk0 |
创建设备文件有两种方式:
- 手动创建:使用
mknod命令,例如mknod /dev/mydevice c 240 0。 - 自动创建:驱动中使用
class_create和device_create接口,由 udev/mdev 自动在/dev下生成设备节点。这是 Android 和现代 Linux 系统的推荐做法。
3.2 主设备号与次设备号
每个设备文件都对应一个设备号,由主设备号(major number)和次设备号(minor number)组成。设备号是内核识别和定位驱动的关键标识。
| 概念 | 位数 | 作用 | 示例值 |
|---|---|---|---|
| 主设备号 | 12 位 (0~4095) | 标识设备对应的驱动程序 | 240(表示某个字符设备驱动) |
| 次设备号 | 20 位 (0~1048575) | 标识同一驱动管理的不同设备实例 | 0、1、2(表示同一驱动的多个设备) |
避坑提醒:主设备号不能重复,否则会导致驱动注册失败。开发调试阶段建议使用
alloc_chrdev_region 动态分配主设备号,避免与已有设备号冲突。生产驱动中,可在 /proc/devices 查看当前系统已占用的主设备号。
设备号在内核中使用 dev_t 类型表示(32 位无符号整数)。内核提供了以下宏来操作设备号:
#include <linux/types.h>
#include <linux/kdev_t.h>
dev_t devno; // 设备号变量
int major = MAJOR(devno); // 从 dev_t 提取主设备号
int minor = MINOR(devno); // 从 dev_t 提取次设备号
devno = MKDEV(major, minor); // 由主次设备号合成 dev_t
设备号注册与释放的典型流程:
- 静态注册:
register_chrdev_region(dev_t from, unsigned count, const char *name)— 已知主设备号时使用。 - 动态分配:
alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)— 推荐方式,由内核自动分配未使用的主设备号。 - 释放:
unregister_chrdev_region(dev_t from, unsigned count)— 模块卸载时必须调用。
3.3 file_operations 结构体
file_operations 是字符设备驱动的核心数据结构,定义在 <linux/fs.h> 中。它是一组函数指针的集合,将用户空间的系统调用与内核驱动的具体实现绑定在一起。
以下是字符设备驱动最常用的成员:
| 函数指针 | 系统调用 | 功能描述 |
|---|---|---|
open |
open() |
打开设备,进行初始化、权限检查、递增引用计数 |
release |
close() |
关闭设备,释放资源、递减引用计数 |
read |
read() |
从设备读取数据到用户空间 |
write |
write() |
从用户空间写入数据到设备 |
unlocked_ioctl |
ioctl() |
设备控制命令处理(本章实战核心) |
llseek |
lseek() |
修改文件读写位置(字符设备通常不支持) |
重点说明:在 Linux 2.6.36 之后,
ioctl 字段被替换为 unlocked_ioctl(不带大内核锁)。Android 内核中统一使用 unlocked_ioctl,这也是本课程实战部分的核心接口。
典型的 file_operations 定义示例:
#include <linux/fs.h>
#include <linux/uaccess.h>
/* 设备打开函数 */
static int mydev_open(struct inode *inode, struct file *filp) {
pr_info("mydev: device opened\n");
return 0;
}
/* 设备关闭函数 */
static int mydev_release(struct inode *inode, struct file *filp) {
pr_info("mydev: device closed\n");
return 0;
}
/* ioctl 控制函数(实战核心) */
static long mydev_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
pr_info("mydev: ioctl cmd=0x%x\n", cmd);
return 0;
}
/* 绑定 file_operations 结构体 */
static const struct file_operations mydev_fops = {
.owner = THIS_MODULE,
.open = mydev_open,
.release = mydev_release,
.unlocked_ioctl = mydev_ioctl,
.read = NULL, // 暂不支持
.write = NULL, // 暂不支持
};
避坑提醒:file_operations 结构体必须使用
static const 修饰,确保只读且不污染全局命名空间。未实现的函数指针必须显式置为 NULL,内核会自动返回 -ENOTTY 或 -EINVAL。
3.4 模块加载与卸载
Linux 驱动以内核模块(kernel module)的形式存在,可以动态加载到内核或从内核卸载,无需重新编译整个内核。字符设备驱动的生命周期由模块加载和卸载函数管理。
模块的入口与出口函数:
| 函数 | 宏 | 调用时机 | 职责 |
|---|---|---|---|
mydev_init |
module_init(mydev_init) |
insmod 或系统启动时 |
分配设备号、初始化 cdev、添加字符设备、创建 class/device |
mydev_exit |
module_exit(mydev_exit) |
rmmod 或系统关闭时 |
销毁 device/class、删除 cdev、释放设备号 |
完整的字符设备驱动模板(加载与卸载部分):
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#define DEVICE_NAME "mydev"
#define CLASS_NAME "mydev_class"
static int major;
static struct class *mydev_class = NULL;
static struct device *mydev_device = NULL;
static struct cdev mydev_cdev;
/* 模块加载函数 */
static int __init mydev_init(void) {
dev_t devno;
int ret;
/* 1. 动态分配设备号 */
ret = alloc_chrdev_region(&devno, 0, 1, DEVICE_NAME);
if (ret < 0) {
pr_err("mydev: failed to allocate major number\n");
return ret;
}
major = MAJOR(devno);
pr_info("mydev: major number = %d\n", major);
/* 2. 初始化 cdev 并绑定 file_operations */
cdev_init(&mydev_cdev, &mydev_fops);
mydev_cdev.owner = THIS_MODULE;
/* 3. 添加 cdev 到内核 */
ret = cdev_add(&mydev_cdev, devno, 1);
if (ret < 0) {
pr_err("mydev: failed to add cdev\n");
unregister_chrdev_region(devno, 1);
return ret;
}
/* 4. 创建 device class 和设备节点 */
mydev_class = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(mydev_class)) {
pr_err("mydev: failed to create class\n");
cdev_del(&mydev_cdev);
unregister_chrdev_region(devno, 1);
return PTR_ERR(mydev_class);
}
mydev_device = device_create(mydev_class, NULL, devno, NULL, DEVICE_NAME);
if (IS_ERR(mydev_device)) {
pr_err("mydev: failed to create device\n");
class_destroy(mydev_class);
cdev_del(&mydev_cdev);
unregister_chrdev_region(devno, 1);
return PTR_ERR(mydev_device);
}
pr_info("mydev: driver loaded successfully\n");
return 0;
}
/* 模块卸载函数 */
static void __exit mydev_exit(void) {
dev_t devno = MKDEV(major, 0);
device_destroy(mydev_class, devno); // 销毁设备节点
class_destroy(mydev_class); // 销毁 class
cdev_del(&mydev_cdev); // 删除 cdev
unregister_chrdev_region(devno, 1); // 释放设备号
pr_info("mydev: driver unloaded\n");
}
module_init(mydev_init);
module_exit(mydev_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Android Kernel Driver Course");
MODULE_DESCRIPTION("A simple character device driver example");
最佳实践:模块加载函数使用
__init 标记,卸载函数使用 __exit 标记。这些标记告诉内核:加载完成后可以释放 init 函数占用的内存(如果内核配置了 CONFIG_MODULE_FORCE_UNLOAD)。所有错误路径都必须确保已分配的资源被正确释放,否则会导致内核泄漏。
本章小结:字符设备驱动的四个基础概念环环相扣——设备文件是用户空间的访问入口,主/次设备号是内核定位驱动的钥匙,file_operations 是驱动功能的函数表,模块加载/卸载是驱动生命周期的管理框架。掌握这些概念后,下一章将进入实战环节,编写第一个完整的字符设备驱动并实现 ioctl 命令。