字符设备是 Linux 驱动中最基础、最常用的设备类型,以字节流方式进行数据传输,无缓存、按序读写,串口、键盘、RTC、LED 等均为典型的字符设备。本文从内核架构层面出发,详解字符设备驱动的通用设计框架,涵盖用户空间与内核空间的交互原理、核心内核 API 调用、设备号管理、驱动注册与注销、设备文件创建等关键环节,并给出标准化的代码实现模板,无需涉及具体硬件寄存器操作,可直接适配各类伪设备 / 实际字符设备的驱动开发。
资料获取:字符设备通用框架
1. 字符设备驱动核心架构:用户 - 内核 - 硬件的交互逻辑
字符设备驱动是连接用户空间应用程序与硬件设备的中间层,核心依托 Linux 内核的虚拟文件系统(VFS) 实现跨空间的系统调用转发,整体交互流程呈分层结构,无硬件相关的伪设备驱动可简化为用户空间 - VFS - 字符设备驱动三层架构。
1.1 整体交互流程
- 用户空间:应用程序通过标准 C 库调用
open/read/write/close等系统调用,操作/dev目录下的设备文件(如/dev/pseudo_chrdev); - VFS 层:内核虚拟文件系统接收系统调用,解析设备文件对应的设备号,在驱动注册列表中匹配对应的字符设备驱动;
- 驱动层:VFS 将用户请求转发至驱动中实现的
file_operations文件操作接口,由驱动完成实际的数据读写 / 设备控制; - 硬件层:驱动通过操作硬件寄存器、设备树等完成与物理设备的交互(伪设备驱动可省略此层,用内存模拟数据读写)。
1.2 核心设计原则
- 与硬件解耦:驱动框架本身不涉及具体硬件操作,仅提供标准化接口,硬件相关逻辑在接口实现中单独编写;
- 依托 VFS 注册:驱动必须向 VFS 注册设备号和文件操作接口,否则用户空间无法感知设备;
- 动态资源管理:设备号、类、设备文件等资源采用动态分配方式,避免静态指定导致的资源冲突;
- 对称的注册与注销:驱动加载时注册的所有资源,在卸载时必须按逆序注销,防止内核内存泄漏。
2. 字符设备驱动核心基础:设备号管理
设备号是 VFS 匹配设备文件与驱动的唯一标识,是字符设备驱动开发的第一步,内核通过dev_t类型管理设备号,所有操作均需包含头文件#include <linux/fs.h>和#include <linux/kdev_t.h>。
2.1 设备号的组成
dev_t是 32 位无符号整数,分为主设备号和次设备号:
- 主设备号(12 位):标识驱动程序,同一个驱动对应唯一的主设备号;
- 次设备号(20 位):标识驱动管理的具体设备实例,一个驱动可管理多个设备实例(如多个串口),对应不同的次设备号。
例如设备号100:0,主设备号100表示驱动,次设备号0表示该驱动的第 1 个设备实例。
2.2 设备号的核心操作宏
内核提供专用宏完成主 / 次设备号与dev_t的相互转换,定义在linux/kdev_t.h中:
// 从dev_t中提取主设备号
#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS))
// 从dev_t中提取次设备号
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK))
// 将主/次设备号组合为dev_t类型
#define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi))
其中MINORBITS=20,MINORMASK=(1U << 20) - 1,为内核默认定义。
2.3 设备号的分配方式
推荐使用动态分配(避免静态指定主设备号导致的冲突),内核 API 原型:
// 动态分配设备号
extern int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
- 参数说明:
dev:输出参数,保存分配到的起始设备号;baseminor:起始次设备号,一般设为 0;count:分配的设备号数量,一个设备实例设为 1;name:设备名(非设备文件名),用于内核标识,可通过/proc/devices查看。
- 返回值:成功返回 0,失败返回负的错误码。
静态分配(不推荐):适用于确定主设备号无冲突的场景,API 为register_chrdev_region(dev_t dev, unsigned count, const char *name)。
2.4 设备号的注销
驱动卸载时必须注销已分配的设备号,API 原型:
extern void unregister_chrdev_region(dev_t dev, unsigned count);
- 参数与
alloc_chrdev_region一致,dev为分配的起始设备号,count为分配的数量。
3. 字符设备驱动核心结构体:cdev 与 file_operations
Linux 内核通过struct cdev描述字符设备本身,通过struct file_operations定义驱动对系统调用的实现接口,两者是字符设备驱动的核心,操作均需包含头文件#include <linux/cdev.h>。
3.1 字符设备结构体:struct cdev
struct cdev是内核管理字符设备的核心结构体,包含设备的文件操作接口、所属模块、设备号等信息,核心定义:
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; // 设备实例数量
};
- 关键成员:
owner用于防止模块在设备使用时被卸载,ops指向驱动实现的文件操作接口,dev为设备号。
3.2 cdev 的初始化与注册
cdev 的使用分为初始化和注册两步,先初始化再向 VFS 注册,完成驱动与设备号的绑定。
(1)cdev 初始化:cdev_init ()
初始化struct cdev结构体,将其与file_operations绑定,API 原型:
void cdev_init(struct cdev *cdev, const struct file_operations *fops);
- 参数:
cdev:待初始化的 cdev 结构体指针;fops:驱动实现的文件操作接口指针。
- 必做操作:初始化后必须设置
cdev->owner = THIS_MODULE,防止模块被非法卸载。
(2)cdev 注册:cdev_add ()
向 VFS 注册字符设备,完成驱动与设备号的关联,API 原型:
int cdev_add(struct cdev *cdev, dev_t dev, unsigned count);
- 参数:
cdev:已初始化的 cdev 结构体指针;dev:分配到的起始设备号;count:设备实例数量,与alloc_chrdev_region的count一致。
- 返回值:成功返回 0,失败返回负的错误码。
(3)cdev 注销:cdev_del ()
驱动卸载时注销字符设备,API 原型:
void cdev_del(struct cdev *cdev);
- 参数为已注册的 cdev 结构体指针,注销后该设备将从 VFS 中移除。
3. 文件操作接口:struct file_operations
struct file_operations是驱动与 VFS 的接口桥梁,定义了驱动对open/read/write/close等系统调用的具体实现,内核通过函数指针的方式调用驱动实现的方法,核心成员(定义在linux/fs.h):
struct file_operations {
struct module *owner; // 所属模块,设为THIS_MODULE
int (*open) (struct inode *, struct file *); // 打开设备
int (*release) (struct inode *, struct file *); // 关闭设备
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); // 读取设备
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); // 写入设备
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long); // 设备控制
__poll_t (*poll) (struct file *, struct poll_table_struct *); // 轮询操作
loff_t (*llseek) (struct file *, loff_t, int); // 移动文件指针
};
(4)核心接口说明
- open/release:设备的打开 / 关闭接口,可用于设备的初始化 / 资源释放,如初始化缓冲区、使能硬件;
- read/write:设备的数据读写接口,核心是完成内核空间与用户空间的数据拷贝,必须使用内核专用函数
copy_to_user()(内核→用户)和copy_from_user()(用户→内核),禁止直接指针访问(用户空间地址在内核中不可见); - __user:内核注解,标识参数为用户空间地址,用于编译检查,防止非法内存访问。
(5)read/write 核心数据拷贝函数
// 内核空间数据拷贝到用户空间:成功返回0,失败返回未拷贝的字节数
unsigned long copy_to_user(char __user *to, const void *from, unsigned long n);
// 用户空间数据拷贝到内核空间:成功返回0,失败返回未拷贝的字节数
unsigned long copy_from_user(void *to, const char __user *from, unsigned long n);
4. 设备文件的创建:sysfs 与 udev 协同
用户空间通过/dev目录下的设备文件操作设备,设备文件由udev守护进程动态创建,驱动通过内核 API 向sysfs文件系统导出设备信息,udev监听sysfs的事件并自动创建设备文件,无需手动执行mknod命令,核心操作需包含头文件#include <linux/device.h>。
4.1 核心 API:class_create () 与 device_create ()
设备文件的创建分为两步:先创建设备类,再创建设备实例,类用于对设备进行分类管理(如/sys/class/chrdev)。
(1)创建设备类:class_create ()
在/sys/class目录下创建设备类目录,API 原型:
struct class *class_create(struct module *owner, const char *name);
- 参数:
owner:所属模块,设为 THIS_MODULE;name:类名,对应/sys/class/下的目录名。
- 返回值:成功返回类结构体指针,失败返回
ERR_PTR(err)(需用IS_ERR()判断)。
(2)创建设备文件:device_create ()
在指定的设备类下创建设备实例,udev会根据该实例在/dev目录下创建设备文件,API 原型:
struct device *device_create(struct class *cls, struct device *parent,
dev_t devt, void *drvdata, const char *fmt, ...);
- 参数:
cls:已创建的设备类指针;parent:父设备指针,无则设为 NULL;devt:设备号;drvdata:设备私有数据,无则设为 NULL;fmt:设备文件名,对应/dev/下的文件名(如 "pseudo_chrdev")。
- 返回值:成功返回设备结构体指针,失败返回
ERR_PTR(err)。
4.2 设备文件的注销
驱动卸载时按逆序注销设备文件和设备类,防止资源泄漏:
// 注销设备文件,与device_create()对应
void device_destroy(struct class *cls, dev_t devt);
// 注销设备类,与class_create()对应
void class_destroy(struct class *cls);
5. 驱动模块的加载与卸载:module_init/module_exit
Linux 字符设备驱动以内核模块(.ko 文件) 形式动态加载,核心依托module_init和module_exit两个宏完成驱动入口和出口的注册,无需主函数main(),所有操作需包含头文件#include <linux/module.h>。
5.1 模块加载:module_init ()
注册驱动的初始化函数,当执行insmod或modprobe加载驱动时,内核自动调用该函数,宏定义:
#define module_init(initfn)
initfn:驱动初始化函数,原型为static int __init 驱动名_init(void);__init:内核注解,标识该函数仅在模块加载时执行,执行后函数占用的内存会被释放;static:限定函数作用域为当前模块,防止与其他内核模块冲突。
初始化函数的核心工作(按顺序):
- 动态分配设备号;
- 初始化 cdev 结构体并绑定 file_operations;
- 向 VFS 注册 cdev;
- 创建设备类;
- 创建设备文件。
5.2 模块卸载:module_exit ()
注册驱动的注销函数,当执行rmmod或modprobe -r卸载驱动时,内核自动调用该函数,宏定义:
#define module_exit(exitfn)
exitfn:驱动注销函数,原型为static void __exit 驱动名_exit(void);__exit:内核注解,标识该函数仅在模块卸载时执行。
注销函数的核心工作(与初始化逆序):
- 注销设备文件;
- 注销设备类;
- 从 VFS 注销 cdev;
- 注销设备号;
- 释放驱动申请的其他资源(如缓冲区、自旋锁)。
5.3 模块管理命令
# 加载驱动(不处理依赖)
insmod 驱动名.ko
# 加载驱动(自动处理依赖)
modprobe 驱动名.ko
# 卸载驱动
rmmod 驱动名.ko
# 查看已加载的内核模块
lsmod
# 查看已分配的字符设备号
cat /proc/devices
# 查看设备类信息
ls /sys/class/类名/
6. 字符设备驱动通用代码模板(伪设备)
以下为无硬件依赖的伪字符设备驱动模板,用内核内存模拟数据读写,包含所有核心步骤,可直接编译为.ko 文件,适配所有 Linux 内核版本(3.10+),只需根据实际硬件修改read/write接口的实现逻辑。
6.1 完整代码模板
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/kdev_t.h>
#include <linux/device.h>
#include <linux/uaccess.h>
// 宏定义
#define DEV_NAME "pseudo_chrdev" // 设备名(/proc/devices)
#define DEV_FILE_NAME "pseudo_chrdev" // 设备文件名(/dev/)
#define DEV_COUNT 1 // 设备实例数量
#define BUF_SIZE 256 // 数据缓冲区大小
// 全局变量
static dev_t dev_num; // 设备号
static struct cdev chr_dev; // 字符设备结构体
static struct class *chr_dev_class; // 设备类
static struct device *chr_dev_device; // 设备实例
static char dev_buf[BUF_SIZE]; // 内核数据缓冲区
// 文件操作接口声明
static int chrdev_open(struct inode *inode, struct file *filp);
static int chrdev_release(struct inode *inode, struct file *filp);
static ssize_t chrdev_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos);
static ssize_t chrdev_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos);
// 定义文件操作接口
static struct file_operations chrdev_fops = {
.owner = THIS_MODULE,
.open = chrdev_open,
.release = chrdev_release,
.read = chrdev_read,
.write = chrdev_write,
};
// 打开设备
static int chrdev_open(struct inode *inode, struct file *filp)
{
printk(KERN_INFO "chrdev open success!n");
// 硬件初始化逻辑可在此添加(如使能设备、初始化寄存器)
return 0;
}
// 关闭设备
static int chrdev_release(struct inode *inode, struct file *filp)
{
printk(KERN_INFO "chrdev release success!n");
// 硬件资源释放逻辑可在此添加(如禁能设备、释放缓冲区)
return 0;
}
// 读取设备:内核→用户
static ssize_t chrdev_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos)
{
int ret;
// 检查读取长度,超过缓冲区则截断
if (count > BUF_SIZE)
count = BUF_SIZE;
// 内核缓冲区数据拷贝到用户空间
ret = copy_to_user(buf, dev_buf, count);
if (ret != 0) {
printk(KERN_ERR "copy to user failed! ret = %dn", ret);
return -EFAULT;
}
printk(KERN_INFO "chrdev read: %s, count = %ldn", dev_buf, count);
return count;
}
// 写入设备:用户→内核
static ssize_t chrdev_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos)
{
int ret;
// 检查写入长度,超过缓冲区则截断
if (count > BUF_SIZE)
count = BUF_SIZE;
// 清空内核缓冲区
memset(dev_buf, 0, BUF_SIZE);
// 用户空间数据拷贝到内核缓冲区
ret = copy_from_user(dev_buf, buf, count);
if (ret != 0) {
printk(KERN_ERR "copy from user failed! ret = %dn", ret);
return -EFAULT;
}
printk(KERN_INFO "chrdev write: %s, count = %ldn", dev_buf, count);
return count;
}
// 驱动初始化函数
static int __init chrdev_init(void)
{
int ret;
// 1. 动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, DEV_COUNT, DEV_NAME);
if (ret < 0) {
printk(KERN_ERR "alloc chrdev region failed! ret = %dn", ret);
goto err_alloc;
}
printk(KERN_INFO "alloc chrdev region success! major = %d, minor = %dn", MAJOR(dev_num), MINOR(dev_num));
// 2. 初始化cdev并绑定file_operations
cdev_init(&chr_dev, &chrdev_fops);
chr_dev.owner = THIS_MODULE;
// 3. 注册cdev到VFS
ret = cdev_add(&chr_dev, dev_num, DEV_COUNT);
if (ret < 0) {
printk(KERN_ERR "cdev add failed! ret = %dn", ret);
goto err_cdev_add;
}
// 4. 创建设备类
chr_dev_class = class_create(THIS_MODULE, DEV_NAME);
if (IS_ERR(chr_dev_class)) {
printk(KERN_ERR "class create failed!n");
ret = PTR_ERR(chr_dev_class);
goto err_class_create;
}
// 5. 创建设备文件
chr_dev_device = device_create(chr_dev_class, NULL, dev_num, NULL, DEV_FILE_NAME);
if (IS_ERR(chr_dev_device)) {
printk(KERN_ERR "device create failed!n");
ret = PTR_ERR(chr_dev_device);
goto err_device_create;
}
printk(KERN_INFO "chrdev init success!n");
return 0;
// 错误处理:逆序释放资源
err_device_create:
class_destroy(chr_dev_class);
err_class_create:
cdev_del(&chr_dev);
err_cdev_add:
unregister_chrdev_region(dev_num, DEV_COUNT);
err_alloc:
return ret;
}
// 驱动注销函数
static void __exit chrdev_exit(void)
{
// 1. 注销设备文件
device_destroy(chr_dev_class, dev_num);
// 2. 注销设备类
class_destroy(chr_dev_class);
// 3. 注销cdev
cdev_del(&chr_dev);
// 4. 注销设备号
unregister_chrdev_region(dev_num, DEV_COUNT);
printk(KERN_INFO "chrdev exit success!n");
}
// 模块注册
module_init(chrdev_init);
module_exit(chrdev_exit);
// 模块元数据(必须添加)
MODULE_LICENSE("GPL"); // 许可证,GPL兼容,否则内核加载时报警
MODULE_AUTHOR("XXX"); // 驱动作者
MODULE_DESCRIPTION("Linux Pseudo Character Device Driver"); // 驱动描述
MODULE_VERSION("V1.0"); // 驱动版本
2. 核心说明
- 错误处理:初始化过程中任何一步失败,都需通过
goto语句逆序释放已申请的资源,防止内核内存泄漏; - 内核打印:使用
printk()而非printf(),KERN_INFO/KERN_ERR为日志级别,可通过dmesg查看打印信息; - 数据拷贝:严格使用
copy_to_user/copy_from_user完成跨空间数据传输,避免直接访问用户空间地址; - 模块元数据:
MODULE_LICENSE("GPL")是必加项,否则内核会认为驱动为非开源,加载时触发警告并禁用部分内核功能。
3. 编译 Makefile 模板
创建Makefile文件,与驱动代码同目录,修改KERNELDIR为当前系统的内核源码路径:
KERNELDIR ?= /lib/modules/$(shell uname -r)/build # 系统内核源码路径
PWD := $(shell pwd)
obj-m := pseudo_chrdev.o # 驱动模块名,与代码文件名一致
all:
make -C $(KERNELDIR) M=$(PWD) modules
clean:
make -C $(KERNELDIR) M=$(PWD) clean
编译命令:make,生成pseudo_chrdev.ko驱动模块。
4. 测试应用程序(用户空间)
创建test.c,编译为可执行文件,用于测试驱动的read/write接口:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define DEV_FILE "/dev/pseudo_chrdev"
#define BUF_SIZE 256
int main()
{
int fd;
char buf[BUF_SIZE];
// 打开设备文件
fd = open(DEV_FILE, O_RDWR);
if (fd < 0) {
perror("open dev file failed");
return -1;
}
// 写入数据
strcpy(buf, "Hello Linux Character Device!");
write(fd, buf, strlen(buf));
// 清空缓冲区并读取数据
memset(buf, 0, BUF_SIZE);
read(fd, buf, BUF_SIZE);
printf("read from dev: %sn", buf);
// 关闭设备文件
close(fd);
return 0;
}
编译与运行:
gcc test.c -o test
sudo ./test
dmesg # 查看内核打印信息
7. 字符设备驱动开发关键注意事项
- 资源冲突避免:所有内核资源(设备号、类名、设备文件名)均采用动态分配 / 命名,避免与系统原有设备冲突;
- 内存访问安全:用户空间地址在内核中不可直接访问,必须使用
copy_to_user/copy_from_user完成数据传输,失败时返回-EFAULT; - 并发访问保护:若驱动被多个进程同时访问,需添加并发保护机制(如自旋锁
spinlock_t、互斥体mutex_t),防止数据竞争; - 错误码返回规范:内核驱动的错误码需遵循 Linux 内核规范,如
-EINVAL(参数无效)、-EFAULT(内存访问错误)、-ENOMEM(内存分配失败); - 日志打印规范:使用
printk()打印关键信息,避免过多打印影响内核性能,日志级别根据信息类型选择(KERN_INFO/KERN_ERR/KERN_WARNING); - 模块卸载安全:确保驱动卸载时,设备已被关闭,无进程持有设备文件的文件描述符,可通过
try_module_get()和module_put()实现模块引用计数管理。
Linux 字符设备驱动的通用框架围绕VFS 核心展开,核心流程可概括为设备号分配→cdev 初始化与注册→设备类与设备文件创建→file_operations 接口实现,驱动的加载与卸载严格遵循对称的资源管理原则。
本文提供的代码模板为硬件无关的伪设备驱动,剥离了具体的硬件操作逻辑,仅保留通用框架,实际开发中只需在open/release/read/write接口中添加硬件相关的操作(如寄存器配置、数据读写、中断处理),即可快速适配各类字符设备的驱动开发。
掌握该通用框架后,可轻松开发串口、LED、按键、RTC 等常见字符设备的驱动,是嵌入式 Linux 驱动开发的基础,也是后续学习块设备、网络设备驱动的前提。
314