理解并使用 Linux 内核的字符设备
理解并使用 Linux 内核的字符设备
1. 引言
1.1 什么是字符设备
字符设备是 Linux 中的一类设备,支持以字符为单位进行数据传输。与块设备不同,字符设备不需要缓冲区,即数据是逐字节直接传递的。典型的字符设备包括串口、键盘、鼠标、伪终端等。
用个简单的比喻:字符设备像流水线,生产(写)和消费(读)可以同时进行且无需额外的仓库(缓冲区)。
1.2 字符设备的用途与典型应用场景
字符设备的主要用途是与硬件直接交互,比如读取传感器数据或控制某些外设。典型场景包括:
- 提供用户空间与硬件交互的接口。
- 模拟设备,用于调试或测试。
- 创建自定义的和应用层通信的方法。
1.3 字符设备的特点(与块设备的对比)
特点 | 字符设备 | 块设备 |
---|---|---|
数据传输单位 | 字符(逐字节) | 块(通常为 512 字节或更大) |
是否有缓冲区 | 无(直接传递) | 有 |
典型场景 | 键盘、串口 | 磁盘、U盘 |
接口 | file_operations 的方法实现 | I/O 调度层支持 |
2. 编写一个简单的字符设备
下文所有代码都基于6.9.1
内核
2.1 示例代码及功能介绍
以下是一个简单的字符设备驱动示例,功能是从用户空间读取数据并将其回显。此代码展示了字符设备的核心操作流程,适合入门学习。
创建main.c
文件如下
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/uaccess.h> // 用于copy_to_user和copy_from_user
#define DEVICE_NAME "simple_char_device" // 设备名称
static int major; // 主设备号
static char message[256] = {0}; // 缓存区,用于存储用户写入的数据
static int open_count = 0; // 打开设备的次数计数器
// 打开设备
static int device_open(struct inode *inode, struct file *file) {
open_count++;
printk(KERN_INFO "Device opened %d time(s)\n", open_count);
return 0; // 成功返回0
}
// 读取设备数据到用户空间
static ssize_t device_read(struct file *file, char __user *buffer, size_t len, loff_t *offset) {
size_t message_len = strlen(message); // 获取消息长度
if (*offset >= message_len) // 如果偏移量超出消息长度,返回0表示EOF
return 0;
if (len > message_len - *offset) // 如果读取长度超过剩余数据,截取剩余部分
len = message_len - *offset;
if (copy_to_user(buffer, message + *offset, len)) // 数据拷贝到用户空间
return -EFAULT; // 失败返回错误码
*offset += len; // 更新偏移量
return len; // 返回读取的字节数
}
// 写入数据到设备
static ssize_t device_write(struct file *file, const char __user *buffer, size_t len, loff_t *offset) {
if (len > sizeof(message) - 1) // 检查写入数据是否超出缓冲区
return -EINVAL; // 无效参数错误
memset(message, 0, sizeof(message)); // 清空缓冲区
if (copy_from_user(message, buffer, len)) // 从用户空间拷贝数据
return -EFAULT; // 失败返回错误码
message[len] = '\0'; // 确保字符串以空字符结尾
printk(KERN_INFO "Received: %s\n", message); // 打印接收到的数据
return len; // 返回写入的字节数
}
// 释放设备(关闭)
static int device_release(struct inode *inode, struct file *file) {
printk(KERN_INFO "Device closed\n");
return 0; // 成功返回0
}
// 定义文件操作结构
static struct file_operations fops = {
.open = device_open, // 打开设备
.read = device_read, // 读取设备
.write = device_write, // 写入设备
.release = device_release, // 释放设备
};
// 模块初始化函数
static int __init char_device_init(void) {
// 动态注册字符设备,获取主设备号
major = register_chrdev(0, DEVICE_NAME, &fops);
if (major < 0) {
printk(KERN_ALERT "Failed to register device\n");
return major; // 返回错误码
}
printk(KERN_INFO "Registered char device with major number %d\n", major);
return 0; // 成功返回0
}
// 模块卸载函数
static void __exit char_device_exit(void) {
unregister_chrdev(major, DEVICE_NAME); // 注销字符设备
printk(KERN_INFO "Unregistered char device\n");
}
module_init(char_device_init); // 指定初始化函数
module_exit(char_device_exit); // 指定卸载函数
MODULE_LICENSE("GPL"); // 模块许可声明
MODULE_AUTHOR("Your Name"); // 模块作者
MODULE_DESCRIPTION("A simple char device driver"); // 模块描述
Makefile
文件如下
obj-m += main.o
all:
# 使用内核源码路径编译模块
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
# 清理编译生成的文件
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
2.2 分步解析示例代码
-
设备号分配:
- 使用
register_chrdev(0, DEVICE_NAME, &fops)
动态分配主设备号,并将其绑定到设备名称。 - 注册失败时返回负值,通常需要打印错误信息以便调试。
- 使用
-
定义
file_operations
结构:file_operations
是字符设备的核心结构,用于描述字符设备的操作行为:.open
:在用户空间调用open
时执行。.read
:用户调用read
读取设备数据时执行。.write
:用户调用write
写入设备数据时执行。.release
:在设备被关闭时调用。
-
与用户空间交互:
copy_to_user
:将内核中的数据拷贝到用户空间,需检查是否返回错误。copy_from_user
:将用户空间数据拷贝到内核,需确保长度合法。- 使用这些函数的原因是内核和用户空间的内存不共享,直接访问可能导致非法访问错误。
-
设备日志输出:
- 使用
printk
打印日志信息,有助于了解设备运行状态。 - 日志可通过
dmesg
命令查看。
- 使用
2.3 测试字符设备
我们可以通过以下步骤测试该字符设备:
- 编译并加载模块:
- 使用
make
编译模块 - 使用
sudo insmod main.ko
命令加载模块。
- 使用
- 创建设备节点:
- 查看主设备号. 使用
sudo dmesg | grep major
命令, 或者cat /proc/devices | grep simple_char_device
- 创建设备:
sudo mknod /dev/simple_char_device c <major_number> 0
- 查看主设备号. 使用
- 测试设备:
- 使用
echo
写入数据:echo "Hello" | sudo tee /dev/simple_char_device
- 使用
cat
读取数据:cat /dev/simple_char_device
- 使用
- 卸载模块:
- 使用
sudo rmmod main.ko
命令卸载模块。
- 使用
3. 深入解析 Linux 内核中的字符设备
字符设备是 Linux 驱动开发中最基础的设备类型之一。通过字符设备,用户可以实现对硬件的读写操作。本节将探讨创建字符设备的不同方式、设备号的分配方法,以及 file_operations
的作用和实现细节。
3.1 创建字符设备的两种方式
方式一:使用 register_chrdev
register_chrdev
是一种简单的字符设备注册方式。通过调用该函数,可以快速注册一个字符设备并关联 file_operations
接口。
示例:
int major = register_chrdev(0, DEVICE_NAME, &fops);
if (major < 0) {
printk(KERN_ALERT "Failed to register device\n");
return major;
}
printk(KERN_INFO "Device registered with major number %d\n", major);
特点:
- 操作简单,适合快速开发和调试。
- 不需要显式创建
struct cdev
对象。 - 功能较有限,推荐用于较简单的场景。
方式二(推荐):使用 cdev
和 cdev_add
cdev
是内核提供的字符设备核心数据结构,使用该方式注册字符设备更加灵活且符合现代驱动开发规范。
步骤:
- 初始化字符设备对象:
cdev_init
。 - 分配设备号:
alloc_chrdev_region
。 - 将设备添加到内核:
cdev_add
。
示例:
struct cdev my_cdev;
dev_t dev_num;
// 动态分配设备号
alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
major = MAJOR(dev_num);
// 初始化字符设备
cdev_init(&my_cdev, &fops);
my_cdev.owner = THIS_MODULE;
// 注册到内核
if (cdev_add(&my_cdev, dev_num, 1) < 0) {
printk(KERN_ALERT "Failed to add cdev\n");
unregister_chrdev_region(dev_num, 1);
return -1;
}
printk(KERN_INFO "Device registered with major number %d\n", major);
特点:
- 适合复杂设备驱动程序的开发。
- 提供更细粒度的控制,例如支持同时创建多个设备, 配合
device_create
自动创建设备等。
3.2 分配设备号:静态与动态分配
设备号由 主设备号 和 次设备号 组成。主设备号标识驱动程序类型,次设备号标识具体的设备实例。主次设备号加在一起就可以唯一标识一个具体的设备。
静态分配
开发者可以直接指定设备号。这种方式简单,但可能与其他驱动冲突。
示例:
#define MAJOR_NUM 240
register_chrdev(MAJOR_NUM, DEVICE_NAME, &fops);
优缺点:
- 优点:便于调试和定位。
- 缺点:设备号固定,可能与其他模块冲突。
动态分配
动态分配通过内核自动分配主设备号,推荐在现代开发中使用。
使用 register_chrdev
分配设备号:
int major = register_chrdev(0, DEVICE_NAME, &fops);
if (major < 0) {
printk(KERN_ALERT "Failed to register device\n");
return major;
}
printk(KERN_INFO "Device registered with major number %d\n", major);
使用 alloc_chrdev_region
分配设备号:
dev_t dev_num;
alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
major = MAJOR(dev_num);
动态分配的设备号可以通过 /proc/devices
查看。
以下是补充和完善后的 3.3 理解 file_operations
与字符设备的交互原理 章节内容。包括技术细节的完善和错误的修正,同时以逻辑清晰的方式组织内容:
3.3 理解 file_operations
与字符设备的交互原理
file_operations
结构体定义了一组操作回调函数,用于描述字符设备如何响应来自用户空间的操作请求。这些回调函数实现了设备驱动程序与用户空间之间的接口,覆盖了文件操作的各个方面(如打开、读写、关闭等)。为了深入理解字符设备如何通过 file_operations
实现交互,我们需要从设备号与文件系统的关系、设备的注册过程,以及文件操作的调用链三方面入手。
1. 设备号与文件系统的关系
-
设备号
- 每个字符设备通过设备号唯一标识,由主设备号 (
major
) 和次设备号 (minor
) 组成。- 主设备号:标识负责管理该类设备的驱动程序。
- 次设备号:区分同一驱动程序下的不同设备实例。
- 每个字符设备通过设备号唯一标识,由主设备号 (
-
设备节点
- 字符设备在文件系统中表现为特殊文件,称为设备节点(例如
/dev/my_device
)。设备节点的inode
结构包含了对应的设备号。 - 用户空间程序通过系统调用(如
open
)访问设备节点,内核通过解析设备号找到对应的驱动程序,并最终调用file_operations
中的回调函数。
- 字符设备在文件系统中表现为特殊文件,称为设备节点(例如
2. 字符设备的注册与绑定
为了让字符设备能被内核管理并提供给用户空间使用,驱动程序需要完成设备的注册和 file_operations
的绑定。这个过程分为以下步骤:
-
设备号的分配
- 使用
alloc_chrdev_region
动态分配主设备号和次设备号范围。 - 或者,使用
register_chrdev_region
手动指定设备号范围。
dev_t dev_num; alloc_chrdev_region(&dev_num, 0, 1, "my_device");
- 使用
-
初始化
cdev
结构- 每个字符设备通过
struct cdev
表示,其核心字段ops
指向设备驱动的file_operations
。 - 使用
cdev_init
初始化struct cdev
。
struct cdev my_cdev; cdev_init(&my_cdev, &my_fops);
- 每个字符设备通过
-
将
cdev
添加到内核- 使用
cdev_add
将设备添加到内核,建立设备号与cdev
的映射。 cdev_add
会将设备号插入到kobj_map
结构中,以便后续通过设备号快速找到对应的cdev
和file_operations
。
cdev_add(&my_cdev, dev_num, 1);
- 使用
-
创建设备节点
- 使用
mknod
命令创建设备节点,或者通过用户空间的设备管理工具(如udev
)自动完成。
- 使用
3. 文件操作的调用链
以下是用户空间程序调用字符设备时的调用链和关键步骤:
用户调用 open
系统调用
- 用户程序调用
open("/dev/my_device", ...)
。 - 内核通过文件系统找到
/dev/my_device
对应的inode
,并从中获取设备号(主设备号和次设备号)。
内核解析设备号并找到 cdev
在以前老的内核中, 内核通过主设备号,从 chrdevs
哈希表(chrdevs[CHRDEV_MAJOR_HASH_SIZE]
)中找到注册的字符设备。现在已经弃用了这种方式。现在使用 chrdev_open
函数,通过次设备号在 kobj_map
中查找对应的 struct cdev
。
static int chrdev_open(struct inode *inode, struct file *file)
{
struct cdev *p = kobj_lookup(cdev_map, inode->i_rdev, NULL);
if (!p)
return -ENODEV;
file->f_op = p->ops;
if (file->f_op->open)
return file->f_op->open(inode, file);
return 0;
}
绑定 file_operations
-
内核通过
struct cdev
的ops
字段获取对应的file_operations
结构,并初始化file->f_op
。 -
内核调用
file_operations
中的open
回调函数,完成设备打开。调用链总结:
用户程序 -> open() -> vfs_open() -> chrdev_open() -> cdev->ops->open()
小结
file_operations
是字符设备的操作接口,通过一系列回调函数实现用户空间与设备的交互。- 字符设备通过主设备号和次设备号唯一标识,并通过
cdev
结构与file_operations
绑定。 - 内核通过
chrdev_open
和kobj_map
将设备号解析为file_operations
,从而实现了用户空间系统调用与设备驱动的衔接。
4. 创建设备节点
设备节点是用户空间与内核设备驱动程序交互的入口。在 Linux 中,字符设备需要一个设备节点(如 /dev/simple_char_device
)供用户访问。
4.1 用户手动创建设备节点
设备节点可以通过 mknod
命令手动创建。
语法如下:
sudo mknod /dev/simple_char_device c <major> <minor>
参数说明:
/dev/simple_char_device
:设备节点的路径。c
:设备类型,c
表示字符设备,b
表示块设备。<major>
:主设备号,用于标识字符设备驱动程序。<minor>
:次设备号,用于区分驱动程序中的不同设备实例。
示例:
假设主设备号为 240,次设备号为 0:
sudo mknod /dev/simple_char_device c 240 0
sudo chmod 666 /dev/simple_char_device # 设置读写权限
用户空间通过设备节点与字符设备交互。例如:
echo "Hello" > /dev/simple_char_device
cat /dev/simple_char_device
缺点:
- 手动创建节点不方便,且设备号可能在系统重启或驱动加载时发生变化。
4.2 使用内核代码配合 udev 动态创建设备节点
现代 Linux 系统中,推荐通过内核和 udev 配合实现设备节点的自动创建。内核代码通过创建设备类和设备对象,通知 udev 守护进程自动创建设备节点。
核心函数:
class_create
:创建设备类,在/sys/class
下注册。device_create
:为设备类添加设备,在/sys/class/<class_name>/<device_name>
下注册。
完整代码示例:
以下是一个字符设备驱动中动态创建设备节点的示例:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/device.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#include <linux/version.h>
#define DEVICE_NAME "simple_char_device"
#define CLASS_NAME "simple_char_class"
static int major; // 主设备号
static struct class *char_class; // 设备类
static struct device *char_device; // 设备对象
// 文件操作函数
static int dev_open(struct inode *inode, struct file *file) {
printk(KERN_INFO "Device opened\n");
return 0;
}
static ssize_t dev_read(struct file *file, char __user *buffer, size_t len, loff_t *offset) {
char *msg = "Hello from kernel!";
size_t msg_len = strlen(msg);
if (*offset >= msg_len)
return 0;
if (len > msg_len - *offset)
len = msg_len - *offset;
if (copy_to_user(buffer, msg + *offset, len))
return -EFAULT;
*offset += len;
return len;
}
static ssize_t dev_write(struct file *file, const char __user *buffer, size_t len, loff_t *offset) {
printk(KERN_INFO "Data written to device\n");
return len;
}
static int dev_release(struct inode *inode, struct file *file) {
printk(KERN_INFO "Device closed\n");
return 0;
}
// 文件操作结构体
static struct file_operations fops = {
.open = dev_open,
.read = dev_read,
.write = dev_write,
.release = dev_release,
};
static int __init char_init(void) {
// 动态分配主设备号
major = register_chrdev(0, DEVICE_NAME, &fops);
if (major < 0) {
printk(KERN_ALERT "Failed to register char device\n");
return major;
}
printk(KERN_INFO "Registered char device with major number %d\n", major);
// 创建设备类
#if LINUX_VERSION_CODE < KERNEL_VERSION(3, 11, 0)
char_class = class_create(THIS_MODULE, CLASS_NAME);
#else
char_class = class_create(CLASS_NAME);
#endif
if (IS_ERR(char_class)) {
unregister_chrdev(major, DEVICE_NAME);
printk(KERN_ALERT "Failed to create class\n");
return PTR_ERR(char_class);
}
// 创建设备
char_device = device_create(char_class, NULL, MKDEV(major, 0), NULL, DEVICE_NAME);
if (IS_ERR(char_device)) {
class_destroy(char_class);
unregister_chrdev(major, DEVICE_NAME);
printk(KERN_ALERT "Failed to create device\n");
return PTR_ERR(char_device);
}
printk(KERN_INFO "Device created successfully\n");
return 0;
}
static void __exit char_exit(void) {
device_destroy(char_class, MKDEV(major, 0)); // 销毁设备
class_destroy(char_class); // 销毁类
unregister_chrdev(major, DEVICE_NAME); // 注销设备号
printk(KERN_INFO "Char device unregistered\n");
}
module_init(char_init);
module_exit(char_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Simple char device with auto node creation");
关键步骤:
- 动态分配主设备号:使用
register_chrdev
。 - 创建设备类:
class_create
创建设备类,在/sys/class
下可见。 - 创建设备对象:
device_create
将设备注册到/sys/class/<class_name>
。 - 加载驱动时创建设备节点:udev 守护进程会在
/dev
中自动创建设备节点。
udev 自动创建节点的工作原理:
- 内核通过
class_create
和device_create
向/sys/class
添加设备信息。 - udev 监听
/sys
文件系统的事件,发现新设备时根据设备属性规则自动创建节点。
4.3 查看设备节点信息
查看设备类和设备信息:
加载驱动后,可以通过以下命令查看设备信息:
ls /sys/class/simple_char_class
查看设备号:
通过 dmesg
日志获取主设备号和次设备号:
dmesg | grep "Registered char device"
4.4 小结
- 手动创建:通过
mknod
创建设备节点,但需要指定设备号,手动管理麻烦。 - 自动创建:结合
class_create
和device_create
配合 udev,实现设备节点的动态创建,现代驱动开发的推荐方式。
通过动态分配设备号和自动创建设备节点,字符设备驱动的加载、管理和用户访问变得更加简洁和高效。
5. 总结
本文介绍了Linux内核中的字符设备,这是一种支持逐字节数据传输的设备类型,与块设备相比不需要缓冲区。字符设备广泛用于直接硬件交互,如读取传感器或控制外设。文中详细描述了编写简单字符设备驱动的过程,包括定义file_operations
结构来处理打开、读写和关闭操作,以及使用register_chrdev
动态分配主设备号。
进一步探讨了创建字符设备的不同方法,强调了使用cdev
结构和cdev_add
函数的优势,这种方式提供了更灵活的控制,适合复杂场景。同时讨论了设备号静态与动态分配的区别,指出动态分配是现代开发中的推荐做法。对于file_operations
的作用机制,文章解释了它如何作为接口实现用户空间与字符设备之间的交互,并深入分析了从用户调用到内核响应的整个过程。
最后,针对设备节点的创建,提出了两种方式:一是用户手动通过mknod
命令创建;二是利用内核代码配合udev规则自动创建,后者在现代系统中更为常见且便捷。