3. 字符设备驱动基础(一):字符设备的概念与主次设备号、设备号分配(静态与动态)、cdev结构体与file_operations接口、实现简单的open/read/write/close
3.1 字符设备的概念与本质
在 Linux 内核中,设备驱动被划分为三大类:字符设备、块设备和网络设备。字符设备是其中最基础、最常用的一类,也是嵌入式驱动开发入门的首选。
字符设备(Character Device) 是指以字节流(Byte Stream)形式进行数据传输的设备。应用程序对字符设备的访问,通常通过 open()、read()、write()、close() 等标准系统调用完成,数据按顺序、无缓冲地逐字节或逐块传输。
典型字符设备举例: 串口(UART)、LED 灯、按键、GPIO、I2C/SPI 从设备、温度传感器、蜂鸣器、LCD 屏(帧缓冲设备除外)。几乎所有“非块、非网络”的设备都可以归为字符设备。
核心特征: 字符设备不经过内核的页缓存(Page Cache),数据直接在内核空间与用户空间之间拷贝。这使得字符设备驱动天然适合实时性要求高、数据量小或控制类场景。
3.2 主设备号与次设备号
Linux 内核通过 设备号(Device Number) 来唯一标识一个设备。设备号是一个 dev_t 类型的 32 位无符号整数,内部划分为两部分:
| 组成部分 | 位宽 | 作用 | 示例 |
|---|---|---|---|
| 主设备号(Major Number) | 高 12 位 | 标识设备对应的驱动程序。内核通过主设备号找到对应的 cdev 结构体。 |
89(I2C 设备) |
| 次设备号(Minor Number) | 低 20 位 | 标识由同一驱动管理的多个物理或逻辑设备实例。 | 0、1、2 |
注意: 主设备号
0 和 255 为系统保留。主设备号 1 ~ 255 中,部分已被内核预定义(如 1 为内存设备,4 为 tty 设备)。自定义驱动应避免与已有主设备号冲突。
内核提供了以下宏来操作 dev_t:
#include <linux/types.h>
dev_t dev = MKDEV(major, minor); // 由主次设备号合成 dev_t
int major = MAJOR(dev); // 从 dev_t 提取主设备号
int minor = MINOR(dev); // 从 dev_t 提取次设备号
3.3 设备号分配:静态分配 vs 动态分配
在注册字符设备之前,驱动必须获得一个可用的设备号。Linux 内核提供两种分配策略:
| 分配方式 | 函数 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 静态分配 | register_chrdev_region() |
设备号固定,便于用户空间创建设备节点。 | 需要手动查询 /proc/devices 确保不冲突;维护成本高。 | 驱动已广泛部署,设备号已标准化。 |
| 动态分配 | alloc_chrdev_region() |
自动分配未使用的主设备号,避免冲突。 | 设备号不固定,需通过 /proc/devices 或 udev 动态创建设备节点。 |
绝大多数嵌入式驱动开发的首选。 |
3.3.1 静态分配示例
#include <linux/fs.h>
#define MY_MAJOR 200
#define MY_MINOR 0
#define MY_COUNT 1
dev_t dev;
int ret;
dev = MKDEV(MY_MAJOR, MY_MINOR);
ret = register_chrdev_region(dev, MY_COUNT, "my_device");
if (ret < 0) {
pr_err("Failed to register chrdev region\n");
return ret;
}
3.3.2 动态分配示例
#include <linux/fs.h>
dev_t dev;
int ret;
ret = alloc_chrdev_region(&dev, 0, 1, "my_device");
if (ret < 0) {
pr_err("Failed to alloc chrdev region\n");
return ret;
}
pr_info("Allocated major=%d minor=%d\n", MAJOR(dev), MINOR(dev));
推荐做法: 在模块初始化时使用
alloc_chrdev_region() 动态分配设备号,并在 /proc/devices 中查看分配结果。用户空间可通过 mknod 或 udev 自动创建设备节点。
3.4 cdev 结构体与 file_operations 接口
在 Linux 内核中,每个字符设备都由一个 struct cdev 结构体表示。驱动开发者需要完成以下核心工作:
- 定义并初始化
cdev结构体 - 填充
file_operations结构体,实现 open、read、write、release 等回调函数 - 将
cdev注册到内核,使其与设备号关联
3.4.1 cdev 结构体
#include <linux/cdev.h>
struct cdev {
struct kobject kobj; // 内核对象基类
struct module *owner; // 所属模块(通常设为 THIS_MODULE)
const struct file_operations *ops; // 操作函数集
struct list_head list; // 内核链表节点
dev_t dev; // 设备号
unsigned int count; // 次设备号数量
};
cdev 初始化与注册步骤:
struct cdev my_cdev;
dev_t dev;
// 1. 分配设备号(以动态分配为例)
alloc_chrdev_region(&dev, 0, 1, "my_device");
// 2. 初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 将 cdev 添加到内核
cdev_add(&my_cdev, dev, 1);
3.4.2 file_operations 结构体
file_operations 是字符设备驱动的核心接口表。内核在用户空间发起系统调用时,通过 VFS 层查找该结构体中的对应函数指针并调用。
| 成员 | 函数原型 | 说明 |
|---|---|---|
open |
int (*open)(struct inode *, struct file *) |
打开设备,进行初始化、权限检查、硬件上电等操作。 |
read |
ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) |
从设备读取数据到用户空间缓冲区。 |
write |
ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) |
从用户空间缓冲区写入数据到设备。 |
release |
int (*release)(struct inode *, struct file *) |
关闭设备,释放资源、硬件下电等(对应 close 系统调用)。 |
llseek |
loff_t (*llseek)(struct file *, loff_t, int) |
修改文件读写位置(可选)。 |
关键点:
read 和 write 回调中的缓冲区指针是 __user 标记的,表示指向用户空间。驱动必须使用 copy_to_user() 和 copy_from_user() 进行安全的数据拷贝,不能直接解引用。
3.5 实现简单的 open / read / write / close
下面我们实现一个完整的虚拟字符设备驱动。该驱动不操作真实硬件,而是在内核中维护一个 1KB 的缓冲区,演示完整的 open、read、write、release 流程。
3.5.1 驱动代码框架
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h> // copy_to_user / copy_from_user
#define DEVICE_NAME "my_char_dev"
#define BUFFER_SIZE 1024
static dev_t dev_num;
static struct cdev my_cdev;
static char device_buffer[BUFFER_SIZE];
static int buffer_len = 0;
// open 回调
static int my_open(struct inode *inode, struct file *filp)
{
pr_info("Device opened\n");
return 0;
}
// read 回调
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
size_t available = buffer_len - *f_pos;
size_t to_read = min(count, available);
if (to_read == 0)
return 0;
if (copy_to_user(buf, device_buffer + *f_pos, to_read)) {
pr_err("Failed to copy data to user\n");
return -EFAULT;
}
*f_pos += to_read;
pr_info("Read %zu bytes from device\n", to_read);
return to_read;
}
// write 回调
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos)
{
size_t available = BUFFER_SIZE - *f_pos;
size_t to_write = min(count, available);
if (to_write == 0)
return -ENOSPC;
if (copy_from_user(device_buffer + *f_pos, buf, to_write)) {
pr_err("Failed to copy data from user\n");
return -EFAULT;
}
*f_pos += to_write;
if (*f_pos > buffer_len)
buffer_len = *f_pos;
pr_info("Wrote %zu bytes to device\n", to_write);
return to_write;
}
// release 回调(对应 close)
static int my_release(struct inode *inode, struct file *filp)
{
pr_info("Device closed\n");
return 0;
}
// 定义 file_operations
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
.release = my_release,
};
// 模块初始化
static int __init my_init(void)
{
int ret;
// 1. 动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret < 0) {
pr_err("Failed to allocate device number\n");
return ret;
}
pr_info("Allocated major=%d minor=%d\n", MAJOR(dev_num), MINOR(dev_num));
// 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) {
pr_err("Failed to add cdev\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
pr_info("Character device driver loaded\n");
return 0;
}
// 模块退出
static void __exit my_exit(void)
{
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
pr_info("Character device driver unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Linux Driver Developer");
MODULE_DESCRIPTION("Simple character device driver example");
3.5.2 用户空间测试程序
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd;
char write_buf[] = "Hello from user space!";
char read_buf[64] = {0};
// 打开设备(需要先 mknod /dev/my_char_dev c major 0)
fd = open("/dev/my_char_dev", O_RDWR);
if (fd < 0) {
perror("open");
return -1;
}
// 写入数据
write(fd, write_buf, strlen(write_buf));
// 重置文件偏移到起始位置
lseek(fd, 0, SEEK_SET);
// 读取数据
read(fd, read_buf, sizeof(read_buf));
printf("Read from device: %s\n", read_buf);
// 关闭设备
close(fd);
return 0;
}
测试步骤:
1. 编译驱动模块:
2. 加载模块:
3. 查看设备号:
4. 创建设备节点:
5. 编译并运行测试程序:
6. 卸载模块:
1. 编译驱动模块:
make2. 加载模块:
sudo insmod my_char_dev.ko3. 查看设备号:
cat /proc/devices | grep my_char_dev4. 创建设备节点:
sudo mknod /dev/my_char_dev c [major] 05. 编译并运行测试程序:
gcc test.c -o test && sudo ./test6. 卸载模块:
sudo rmmod my_char_dev
3.6 关键注意事项与常见错误
| 常见错误 | 后果 | 正确做法 |
|---|---|---|
| 直接解引用用户空间指针 | 内核崩溃(Oops) | 使用 copy_to_user() / copy_from_user() |
未检查 copy_to_user 返回值 |
部分数据未拷贝,用户读到脏数据 | 检查返回值,非零时返回 -EFAULT |
未处理文件偏移 f_pos |
多次读写位置错乱 | 在 read/write 中正确更新 *f_pos |
| cdev_add 失败后未释放设备号 | 设备号泄漏 | 在错误路径中调用 unregister_chrdev_region() |
模块退出时未调用 cdev_del |
内核 panic 或设备残留 | 在 __exit 函数中先 cdev_del 再 unregister_chrdev_region |
安全红线: 永远不要在内核空间直接使用
memcpy() 操作来自用户空间的指针。必须使用 copy_from_user() 和 copy_to_user(),这两个函数会进行地址合法性检查,并在访问失败时返回非零值。
3.7 本章小结
- 字符设备 是 Linux 驱动开发的基础,以字节流方式与用户空间交互。
- 设备号 由主设备号(驱动标识)和次设备号(设备实例标识)组成,通过
dev_t管理。 - 设备号分配 推荐使用
alloc_chrdev_region()动态分配,避免冲突。 - cdev 结构体 是字符设备在内核中的核心表示,通过
cdev_init()+cdev_add()注册。 - file_operations 是驱动与 VFS 的接口表,必须实现 open、read、write、release 等回调。
- 用户空间数据拷贝 必须使用
copy_to_user()/copy_from_user(),禁止直接解引用。