目录
- 核心概念:主设备号+次设备号+cdev结构体
1.1 主次设备号:用快递系统一秒懂
1.2 cdev结构体:字符设备的"身份证"
1.3 字符设备注册完整流程图 - 传统非设备树版:完整字符设备驱动模板
2.1 完整源码(注释全覆盖)
2.2 编译&测试步骤 - 核心API:copy_to_user/copy_from_user详解
3.1 为什么绝对不能用memcpy?
3.2 函数原型与使用场景
3.3 必守的5条使用规则
3.4 常见崩溃问题与修正方案 - 静态vs动态设备号:优缺点对比+现代推荐写法
4.1 核心对比表
4.2 两种写法代码示例
4.3 现代驱动最佳实践 - 核心总结+面试必背考点
1. 核心概念:主设备号+次设备号+cdev结构体
1.1 主次设备号:用快递系统一秒懂
Linux系统中,所有设备都以文件 的形式存在于/dev/目录下,而设备号就是内核识别设备的唯一标识,分为主设备号和次设备号两部分。
大白话类比(快递系统)
| 设备号组成 | 快递系统对应 | 作用 |
|---|---|---|
| 主设备号 | 快递公司 | 标识驱动类型,同一类设备(所有串口、所有硬盘)共享同一个主设备号 |
| 次设备号 | 快递员编号 | 标识同类型下的不同具体设备,比如串口1、串口2,硬盘sda、sdb |
举个例子:
/dev/ttyS0(串口1):主设备号4,次设备号0/dev/ttyS1(串口2):主设备号4,次设备号1- 它们用同一个串口驱动(主设备号4),但次设备号区分不同的硬件端口
技术细节
-
设备号用
dev_t类型表示(32位无符号整数) -
高12位 = 主设备号,低20位 = 次设备号
-
内核提供三个宏来操作设备号:
c#define MKDEV(major, minor) ((major) << 20 | (minor)) // 合成设备号 #define MAJOR(dev) ((dev) >> 20) // 提取主设备号 #define MINOR(dev) ((dev) & 0xfffff) // 提取次设备号
1.2 cdev结构体:字符设备的"身份证"
struct cdev是Linux内核用来抽象字符设备的核心结构体,你可以把它理解为字符设备的"身份证",里面记录了这个设备的所有关键信息:
- 设备号(
dev_t dev) - 设备的操作函数集(
struct file_operations *ops) - 所属的内核模块(
struct module *owner) - 引用计数等管理信息
简单说:内核通过cdev结构体,把"设备号"和"驱动的操作函数"关联起来 。当用户操作/dev/xxx设备文件时,内核会根据设备号找到对应的cdev,然后调用cdev里注册的file_operations函数。
1.3 字符设备注册完整流程图
模块初始化
申请设备号
alloc_chrdev_region
初始化cdev结构体
cdev_init
绑定file_operations操作集
向内核注册cdev
cdev_add
创建设备类class_create
生成/dev/设备文件device_create
驱动加载完成,对外提供服务
2. 传统非设备树版:完整字符设备驱动模板
这是工业界通用的传统字符设备驱动标准模板 ,完整实现了open/read/write/release四个核心接口,注释覆盖每一行关键代码,可直接作为你后续驱动开发的基础框架。
2.1 完整源码(注释全覆盖)
c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#include <linux/slab.h>
// 模块信息
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Linux驱动学习");
MODULE_DESCRIPTION("传统非设备树版完整字符设备驱动");
MODULE_VERSION("1.0");
// 自定义配置
#define DEV_NAME "my_full_chrdev" // 设备文件名 /dev/my_full_chrdev
#define CLASS_NAME "my_chr_class" // 设备类名
#define BUF_SIZE 1024 // 内核缓冲区大小
// 全局变量
static dev_t dev_num; // 设备号
static struct cdev chr_cdev; // 字符设备结构体
static struct class *dev_class; // 设备类
static struct device *dev_device; // 设备
static char *kernel_buf; // 内核数据缓冲区
// ===================== 核心文件操作接口 =====================
/**
* @brief 设备打开函数,对应应用层open()
* @param inode 内核inode结构体,包含设备号信息
* @param file 文件结构体,代表打开的文件实例
* @return 0成功,负数失败
*/
static int chr_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "[%s] 设备被打开\n", DEV_NAME);
// 这里可以做:硬件初始化、缓冲区初始化、权限检查等
return 0;
}
/**
* @brief 设备读取函数,对应应用层read()
* @param file 文件结构体
* @param buf 用户态缓冲区指针(__user标记表示这是用户态地址)
* @param len 用户请求读取的字节数
* @param offset 文件偏移量
* @return 成功返回读取的字节数,失败返回负数
*/
static ssize_t chr_read(struct file *file, char __user *buf, size_t len, loff_t *offset)
{
int ret;
size_t read_len = min(len, (size_t)BUF_SIZE);
printk(KERN_INFO "[%s] 用户请求读取%zu字节\n", DEV_NAME, len);
// 1. 校验偏移量
if (*offset >= BUF_SIZE)
return 0; // 已经读到文件末尾
// 2. 调整实际读取长度
if (*offset + read_len > BUF_SIZE)
read_len = BUF_SIZE - *offset;
// 3. 内核数据拷贝到用户态
ret = copy_to_user(buf, kernel_buf + *offset, read_len);
if (ret > 0) {
printk(KERN_ERR "[%s] copy_to_user失败,未拷贝%d字节\n", DEV_NAME, ret);
return -EFAULT;
}
// 4. 更新文件偏移量
*offset += read_len;
printk(KERN_INFO "[%s] 成功读取%zu字节\n", DEV_NAME, read_len);
return read_len;
}
/**
* @brief 设备写入函数,对应应用层write()
* @param file 文件结构体
* @param buf 用户态缓冲区指针
* @param len 用户请求写入的字节数
* @param offset 文件偏移量
* @return 成功返回写入的字节数,失败返回负数
*/
static ssize_t chr_write(struct file *file, const char __user *buf, size_t len, loff_t *offset)
{
int ret;
size_t write_len = min(len, (size_t)BUF_SIZE);
printk(KERN_INFO "[%s] 用户请求写入%zu字节\n", DEV_NAME, len);
// 1. 校验偏移量
if (*offset >= BUF_SIZE)
return -ENOSPC; // 缓冲区已满
// 2. 调整实际写入长度
if (*offset + write_len > BUF_SIZE)
write_len = BUF_SIZE - *offset;
// 3. 用户态数据拷贝到内核态
ret = copy_from_user(kernel_buf + *offset, buf, write_len);
if (ret > 0) {
printk(KERN_ERR "[%s] copy_from_user失败,未拷贝%d字节\n", DEV_NAME, ret);
return -EFAULT;
}
// 4. 更新文件偏移量
*offset += write_len;
printk(KERN_INFO "[%s] 成功写入%zu字节,内核缓冲区内容:%s\n", DEV_NAME, write_len, kernel_buf);
return write_len;
}
/**
* @brief 设备关闭函数,对应应用层close()
* @param inode 内核inode结构体
* @param file 文件结构体
* @return 0成功
*/
static int chr_release(struct inode *inode, struct file *file)
{
printk(KERN_INFO "[%s] 设备被关闭\n", DEV_NAME);
// 这里可以做:资源释放、硬件复位等
return 0;
}
// 文件操作集:绑定应用层系统调用和内核驱动函数
static struct file_operations chr_fops = {
.owner = THIS_MODULE, // 必须设置为THIS_MODULE,防止模块被意外卸载
.open = chr_open, // 对应open()
.read = chr_read, // 对应read()
.write = chr_write, // 对应write()
.release = chr_release, // 对应close()
};
// ===================== 模块入口与出口 =====================
static int __init chr_drv_init(void)
{
int ret;
printk(KERN_INFO "[%s] 驱动开始初始化\n", DEV_NAME);
// 1. 动态申请设备号(现代驱动推荐写法)
ret = alloc_chrdev_region(&dev_num, 0, 1, DEV_NAME);
if (ret < 0) {
printk(KERN_ERR "[%s] 动态申请设备号失败,ret=%d\n", DEV_NAME, ret);
return ret;
}
printk(KERN_INFO "[%s] 申请设备号成功:主=%d,次=%d\n",
DEV_NAME, MAJOR(dev_num), MINOR(dev_num));
// 2. 初始化cdev结构体,绑定文件操作集
cdev_init(&chr_cdev, &chr_fops);
chr_cdev.owner = THIS_MODULE;
// 3. 向内核注册cdev字符设备
ret = cdev_add(&chr_cdev, dev_num, 1);
if (ret < 0) {
printk(KERN_ERR "[%s] 注册cdev失败,ret=%d\n", DEV_NAME, ret);
goto err_unregister_chrdev;
}
// 4. 创建设备类
dev_class = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(dev_class)) {
printk(KERN_ERR "[%s] 创建设备类失败\n", DEV_NAME);
ret = PTR_ERR(dev_class);
goto err_cdev_del;
}
// 5. 自动生成/dev/设备文件
dev_device = device_create(dev_class, NULL, dev_num, NULL, DEV_NAME);
if (IS_ERR(dev_device)) {
printk(KERN_ERR "[%s] 创建设备文件失败\n", DEV_NAME);
ret = PTR_ERR(dev_device);
goto err_class_destroy;
}
// 6. 分配内核数据缓冲区
kernel_buf = kzalloc(BUF_SIZE, GFP_KERNEL);
if (!kernel_buf) {
printk(KERN_ERR "[%s] 分配内核缓冲区失败\n", DEV_NAME);
ret = -ENOMEM;
goto err_device_destroy;
}
printk(KERN_INFO "[%s] 驱动初始化完成\n", DEV_NAME);
return 0;
// 错误处理:反向释放已申请的资源(goto是内核错误处理的标准写法)
err_device_destroy:
device_destroy(dev_class, dev_num);
err_class_destroy:
class_destroy(dev_class);
err_cdev_del:
cdev_del(&chr_cdev);
err_unregister_chrdev:
unregister_chrdev_region(dev_num, 1);
return ret;
}
static void __exit chr_drv_exit(void)
{
printk(KERN_INFO "[%s] 驱动开始卸载\n", DEV_NAME);
// 反向释放所有资源
kfree(kernel_buf);
device_destroy(dev_class, dev_num);
class_destroy(dev_class);
cdev_del(&chr_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "[%s] 驱动卸载完成\n", DEV_NAME);
}
module_init(chr_drv_init);
module_exit(chr_drv_exit);
2.2 编译&测试步骤
1. Makefile
makefile
obj-m += full_chr_dev.o
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNELDIR) M=$(PWD) clean
2. 编译加载
bash
make
sudo insmod full_chr_dev.ko
dmesg | tail # 查看驱动初始化日志
ls /dev/my_full_chrdev # 确认设备文件已生成
3. 测试读写功能
bash
# 写入数据到设备
echo "Hello Linux Driver!" | sudo tee /dev/my_full_chrdev
# 从设备读取数据
cat /dev/my_full_chrdev
# 查看内核日志
dmesg | tail
4. 卸载驱动
bash
sudo rmmod full_chr_dev
make clean
3. 核心API:copy_to_user/copy_from_user详解
这两个函数是用户态与内核态数据交互的唯一安全方式,也是驱动开发中最容易写错、最容易导致崩溃的地方。
3.1 为什么绝对不能用memcpy?
很多新手会问:都是内存拷贝,为什么不能直接用memcpy?核心原因是用户态与内核态内存完全隔离:
- 地址空间不同:用户态地址是虚拟地址,每个进程独立;内核态地址是全局虚拟地址
- 权限检查:用户态地址可能无效、未映射、没有读写权限,直接访问会触发内核Oops崩溃
- 安全防护 :
copy_to/from_user会做完整的地址合法性检查和权限校验,防止恶意攻击
一句话总结:
memcpy是同一个地址空间内的内存拷贝;copy_to/from_user是两个隔离地址空间之间的安全拷贝。
3.2 函数原型与使用场景
1. copy_to_user
c
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);
- 作用:将内核态数据拷贝到用户态缓冲区
- 使用场景 :驱动的
read()函数中,把硬件数据/内核数据返回给用户态应用 - 参数 :
to:用户态缓冲区指针(必须加__user标记)from:内核态缓冲区指针n:要拷贝的字节数
- 返回值 :成功返回0,失败返回未拷贝成功的字节数
2. copy_from_user
c
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);
- 作用:将用户态数据拷贝到内核态缓冲区
- 使用场景 :驱动的
write()函数中,接收用户态应用发送的数据 - 参数 :
to:内核态缓冲区指针from:用户态缓冲区指针(必须加__user标记)n:要拷贝的字节数
- 返回值 :成功返回0,失败返回未拷贝成功的字节数
3.3 必守的5条使用规则
- 必须加
__user标记:标记用户态地址,告诉编译器这是用户态指针,做额外的安全检查 - 必须检查返回值 :返回值>0表示有部分数据未拷贝成功,必须返回
-EFAULT错误 - 必须校验缓冲区大小:防止用户态传入过大的长度,导致内核缓冲区溢出
- 不能在中断上下文使用:这两个函数可能睡眠,只能在进程上下文使用
- 不能拷贝内核地址到内核地址 :同一个地址空间用
memcpy,不要用这两个函数
3.4 常见崩溃问题与修正方案
❌ 问题1:忘记加__user标记
错误写法:
c
static ssize_t chr_read(struct file *file, char *buf, size_t len, loff_t *offset)
正确写法:
c
static ssize_t chr_read(struct file *file, char __user *buf, size_t len, loff_t *offset)
后果:编译警告,运行时可能触发内核Oops崩溃。
❌ 问题2:不检查返回值
错误写法:
c
copy_to_user(buf, kernel_buf, len); // 不检查返回值
return len;
正确写法:
c
int ret = copy_to_user(buf, kernel_buf, len);
if (ret > 0) {
printk(KERN_ERR "拷贝失败,未拷贝%d字节\n", ret);
return -EFAULT;
}
return len - ret;
后果:用户态地址无效时,内核直接崩溃。
❌ 问题3:缓冲区越界
错误写法:
c
// 直接用用户传入的len,不检查内核缓冲区大小
copy_from_user(kernel_buf, buf, len);
正确写法:
c
size_t write_len = min(len, (size_t)BUF_SIZE);
copy_from_user(kernel_buf, buf, write_len);
后果:内核缓冲区溢出,覆盖其他内核数据,导致系统崩溃。
❌ 问题4:直接访问用户态指针
错误写法:
c
// 直接解引用用户态指针
char c = *buf;
正确写法:
c
char c;
if (copy_from_user(&c, buf, 1))
return -EFAULT;
后果:用户态地址无效时,内核直接Oops崩溃。
4. 静态vs动态设备号:优缺点对比+现代推荐写法
4.1 核心对比表
| 对比项 | 静态设备号 | 动态设备号 |
|---|---|---|
| 分配方式 | 开发者手动指定主设备号 | 内核自动分配空闲的主设备号 |
| 核心API | register_chrdev_region() |
alloc_chrdev_region() |
| 优点 | 设备号固定,方便管理 | 不会冲突,自动管理,兼容性好 |
| 缺点 | 容易和其他驱动冲突,需要提前申请 | 设备号不固定,每次加载可能不同 |
| 适用场景 | 内核自带的标准驱动(如串口、硬盘) | 所有第三方驱动、自定义驱动 |
| 现代推荐度 | ⭐⭐ | ⭐⭐⭐⭐⭐ |
4.2 两种写法代码示例
1. 静态设备号写法(不推荐)
c
// 手动指定主设备号,注意不要和系统已有的冲突
#define STATIC_MAJOR 200
#define STATIC_MINOR 0
dev_num = MKDEV(STATIC_MAJOR, STATIC_MINOR);
ret = register_chrdev_region(dev_num, 1, DEV_NAME);
if (ret < 0) {
printk(KERN_ERR "静态注册设备号失败\n");
return ret;
}
2. 动态设备号写法(推荐)
c
// 内核自动分配空闲的主设备号,次设备号从0开始
ret = alloc_chrdev_region(&dev_num, 0, 1, DEV_NAME);
if (ret < 0) {
printk(KERN_ERR "动态申请设备号失败\n");
return ret;
}
printk(KERN_INFO "分配到的设备号:主=%d,次=%d\n", MAJOR(dev_num), MINOR(dev_num));
4.3 现代驱动最佳实践
- 优先使用动态设备号:这是Linux内核社区推荐的标准写法,避免设备号冲突问题
- 不要硬编码主设备号:硬编码的主设备号在不同系统、不同内核版本上很容易冲突
- 使用udev自动管理设备文件 :通过
class_create和device_create自动生成/dev/设备文件,不需要手动mknod - 静态设备号仅用于内核标准驱动:只有内核自带的、已经分配了固定主设备号的驱动才使用静态方式
5. 核心总结+面试必背考点
核心总结
- 主设备号 标识驱动类型,次设备号标识同类型下的不同具体设备
- cdev结构体是字符设备的抽象,把设备号和驱动的操作函数集关联起来
- 字符设备注册流程:申请设备号 → 初始化cdev → 注册cdev → 创建设备类 → 生成设备文件
- copy_to/from_user是用户态与内核态数据交互的唯一安全方式,必须严格遵守使用规则
- 现代驱动优先使用动态设备号,避免设备号冲突,兼容性更好
面试必背考点
- 主设备号和次设备号的作用是什么?如何合成和提取?
- cdev结构体的作用是什么?字符设备注册的完整流程是什么?
- 为什么不能用
memcpy在用户态和内核态之间拷贝数据? copy_to_user和copy_from_user的返回值是什么含义?- 静态设备号和动态设备号的优缺点是什么?现代驱动推荐用哪种?
- 驱动的错误处理为什么要用goto?有什么好处?
file_operations结构体中.owner = THIS_MODULE的作用是什么?