字符设备驱动基础
字符设备是嵌入式Linux驱动开发中最基础、最常用的设备类型。理解字符设备的核心概念、设备编号体系以及驱动框架,是编写第一个驱动程序的前提。本章将系统梳理这些基础知识,并通过图示和表格帮助建立清晰的知识结构。
字符设备概念
字符设备(Character Device)是指以字节流形式进行数据传输的设备。应用程序对字符设备的读写操作是顺序的、无缓冲的,每次读写一个或多个字节,不支持随机访问。典型的字符设备包括:
- 串口(UART)
- 键盘、鼠标
- LED、蜂鸣器
- 温度传感器
- GPIO控制
核心特征:字符设备的数据流像管道一样,数据按字节顺序流动,没有固定的数据块大小限制。驱动中通过 file_operations 结构体提供的 read 和 write 方法实现数据交换。
主设备号与次设备号
Linux内核使用设备编号来唯一标识一个设备。设备编号是一个32位无符号整数,分为两部分:
| 概念 | 位数 | 作用 | 示例 |
|---|---|---|---|
| 主设备号(Major Number) | 高12位 | 标识设备对应的驱动程序 | 1(内存设备)、4(tty设备) |
| 次设备号(Minor Number) | 低20位 | 标识同一驱动管理的不同设备实例 | 0、1、2… |
主设备号决定了内核将操作请求派发给哪个驱动程序;次设备号则让驱动能够区分同一类型下的多个物理或逻辑设备。例如,/dev/tty0 和 /dev/tty1 共享同一个主设备号(4),但次设备号不同(0和1)。
设备编号分配方式:
- 静态分配:在驱动代码中硬编码主设备号(如
MAJOR_NUM 200),适用于私有或实验性驱动。 - 动态分配:调用
alloc_chrdev_region()由内核自动分配未使用的主设备号,推荐用于正式驱动。
设备文件与mknod
在Linux用户空间,设备通过设备文件(Device File)呈现,通常位于 /dev 目录下。设备文件是用户程序与内核驱动之间的桥梁,应用程序通过标准文件操作(open、read、write、close)访问设备文件,内核将操作转发给对应的驱动程序。
创建设备文件的传统命令是 mknod:
# 创建一个字符设备文件
mknod /dev/mydevice c 200 0
# 参数说明:
# c —— 字符设备类型
# 200 —— 主设备号
# 0 —— 次设备号
现代嵌入式开发中,更推荐使用 udev 或 mdev 自动创建设备文件,但理解 mknod 的底层原理对掌握设备文件机制至关重要。
注意:使用 mknod 需要 root 权限。设备文件本身不占用磁盘空间,它只是一个指向内核驱动入口的索引节点(inode)。
字符设备驱动框架:file_operations 结构体
字符设备驱动的核心是 file_operations 结构体,它定义了驱动能够响应的所有文件操作函数指针。内核通过这个结构体将用户空间的系统调用映射到驱动实现的具体函数。
以下是 file_operations 中最常用的成员:
| 成员 | 函数原型 | 触发时机 |
|---|---|---|
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() 发送控制命令 |
llseek |
loff_t (*llseek)(struct file *, loff_t, int) |
用户调用 lseek() 调整文件读写位置 |
一个典型的字符设备驱动框架代码结构如下:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#define DEVICE_NAME "my_char_dev"
#define MAJOR_NUM 200
#define MINOR_NUM 0
static int dev_open(struct inode *inode, struct file *file)
{
pr_info("Device opened\n");
return 0;
}
static int dev_release(struct inode *inode, struct file *file)
{
pr_info("Device closed\n");
return 0;
}
static ssize_t dev_read(struct file *file, char __user *buf,
size_t len, loff_t *off)
{
pr_info("Device read\n");
return 0;
}
static ssize_t dev_write(struct file *file, const char __user *buf,
size_t len, loff_t *off)
{
pr_info("Device write\n");
return len;
}
// 定义 file_operations 结构体
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = dev_open,
.release = dev_release,
.read = dev_read,
.write = dev_write,
};
// 模块初始化
static int __init my_driver_init(void)
{
// 注册主设备号
register_chrdev(MAJOR_NUM, DEVICE_NAME, &fops);
pr_info("Driver registered with major number %d\n", MAJOR_NUM);
return 0;
}
// 模块退出
static void __exit my_driver_exit(void)
{
unregister_chrdev(MAJOR_NUM, DEVICE_NAME);
pr_info("Driver unregistered\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Linux Expert");
MODULE_DESCRIPTION("A simple character device driver");
驱动加载流程:
- insmod 加载模块 → 调用
module_init指定的初始化函数 - 初始化函数调用
register_chrdev注册主设备号和file_operations - 用户通过
mknod创建设备文件,或由udev自动创建 - 应用程序
open("/dev/mydevice")→ 内核找到主设备号对应的驱动 → 调用fops.open - 应用程序
read/write→ 内核调用fops.read/fops.write - rmmod 卸载模块 → 调用
module_exit指定的退出函数 →unregister_chrdev
字符设备驱动整体架构
下面的SVG图展示了从用户空间应用程序到内核驱动再到硬件设备的完整调用链路:
设备号注册与分配流程
下面的SVG图展示了设备号从注册到设备文件创建再到应用程序访问的完整流程:
关键概念对比
| 对比项 | 字符设备 | 块设备 |
|---|---|---|
| 数据传输方式 | 字节流,顺序读写 | 数据块,支持随机访问 |
| 缓冲区 | 无内核缓冲区(直接读写) | 有内核缓冲区(页缓存) |
| 典型设备 | 串口、GPIO、键盘 | 硬盘、SD卡、eMMC |
| 设备文件类型 | c(字符) |
b(块) |
| 驱动核心结构体 | file_operations |
block_device_operations |
学习建议:字符设备驱动是所有驱动开发的基础。建议在开发板上实际编写一个简单的字符设备驱动,使用 mknod 创建设备文件,并通过应用程序验证 open/read/write/close 的完整流程。理解 file_operations 的每个回调函数何时被调用,是掌握驱动开发的关键一步。