news 2026/5/8 16:35:59

【Linux驱动开发】第5天:字符设备驱动核心原理:主次设备号+cdev+数据拷贝全解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【Linux驱动开发】第5天:字符设备驱动核心原理:主次设备号+cdev+数据拷贝全解

目录

  1. 核心概念:主设备号+次设备号+cdev结构体
    1.1 主次设备号:用快递系统一秒懂
    1.2 cdev结构体:字符设备的"身份证"
    1.3 字符设备注册完整流程图
  2. 传统非设备树版:完整字符设备驱动模板
    2.1 完整源码(注释全覆盖)
    2.2 编译&测试步骤
  3. 核心API:copy_to_user/copy_from_user详解
    3.1 为什么绝对不能用memcpy?
    3.2 函数原型与使用场景
    3.3 必守的5条使用规则
    3.4 常见崩溃问题与修正方案
  4. 静态vs动态设备号:优缺点对比+现代推荐写法
    4.1 核心对比表
    4.2 两种写法代码示例
    4.3 现代驱动最佳实践
  5. 核心总结+面试必背考点

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位 = 次设备号
  • 内核提供三个宏来操作设备号:
    #defineMKDEV(major,minor)((major)<<20|(minor))// 合成设备号#defineMAJOR(dev)((dev)>>20)// 提取主设备号#defineMINOR(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 完整源码(注释全覆盖)

#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");// 自定义配置#defineDEV_NAME"my_full_chrdev"// 设备文件名 /dev/my_full_chrdev#defineCLASS_NAME"my_chr_class"// 设备类名#defineBUF_SIZE1024// 内核缓冲区大小// 全局变量staticdev_tdev_num;// 设备号staticstructcdevchr_cdev;// 字符设备结构体staticstructclass*dev_class;// 设备类staticstructdevice*dev_device;// 设备staticchar*kernel_buf;// 内核数据缓冲区// ===================== 核心文件操作接口 =====================/** * @brief 设备打开函数,对应应用层open() * @param inode 内核inode结构体,包含设备号信息 * @param file 文件结构体,代表打开的文件实例 * @return 0成功,负数失败 */staticintchr_open(structinode*inode,structfile*file){printk(KERN_INFO"[%s] 设备被打开\n",DEV_NAME);// 这里可以做:硬件初始化、缓冲区初始化、权限检查等return0;}/** * @brief 设备读取函数,对应应用层read() * @param file 文件结构体 * @param buf 用户态缓冲区指针(__user标记表示这是用户态地址) * @param len 用户请求读取的字节数 * @param offset 文件偏移量 * @return 成功返回读取的字节数,失败返回负数 */staticssize_tchr_read(structfile*file,char__user*buf,size_tlen,loff_t*offset){intret;size_tread_len=min(len,(size_t)BUF_SIZE);printk(KERN_INFO"[%s] 用户请求读取%zu字节\n",DEV_NAME,len);// 1. 校验偏移量if(*offset>=BUF_SIZE)return0;// 已经读到文件末尾// 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);returnread_len;}/** * @brief 设备写入函数,对应应用层write() * @param file 文件结构体 * @param buf 用户态缓冲区指针 * @param len 用户请求写入的字节数 * @param offset 文件偏移量 * @return 成功返回写入的字节数,失败返回负数 */staticssize_tchr_write(structfile*file,constchar__user*buf,size_tlen,loff_t*offset){intret;size_twrite_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);returnwrite_len;}/** * @brief 设备关闭函数,对应应用层close() * @param inode 内核inode结构体 * @param file 文件结构体 * @return 0成功 */staticintchr_release(structinode*inode,structfile*file){printk(KERN_INFO"[%s] 设备被关闭\n",DEV_NAME);// 这里可以做:资源释放、硬件复位等return0;}// 文件操作集:绑定应用层系统调用和内核驱动函数staticstructfile_operationschr_fops={.owner=THIS_MODULE,// 必须设置为THIS_MODULE,防止模块被意外卸载.open=chr_open,// 对应open().read=chr_read,// 对应read().write=chr_write,// 对应write().release=chr_release,// 对应close()};// ===================== 模块入口与出口 =====================staticint__initchr_drv_init(void){intret;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);returnret;}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);gotoerr_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);gotoerr_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);gotoerr_class_destroy;}// 6. 分配内核数据缓冲区kernel_buf=kzalloc(BUF_SIZE,GFP_KERNEL);if(!kernel_buf){printk(KERN_ERR"[%s] 分配内核缓冲区失败\n",DEV_NAME);ret=-ENOMEM;gotoerr_device_destroy;}printk(KERN_INFO"[%s] 驱动初始化完成\n",DEV_NAME);return0;// 错误处理:反向释放已申请的资源(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);returnret;}staticvoid__exitchr_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
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. 编译加载
makesudoinsmod full_chr_dev.kodmesg|tail# 查看驱动初始化日志ls/dev/my_full_chrdev# 确认设备文件已生成
3. 测试读写功能
# 写入数据到设备echo"Hello Linux Driver!"|sudotee/dev/my_full_chrdev# 从设备读取数据cat/dev/my_full_chrdev# 查看内核日志dmesg|tail
4. 卸载驱动
sudormmod full_chr_devmakeclean

3. 核心API:copy_to_user/copy_from_user详解

这两个函数是用户态与内核态数据交互的唯一安全方式,也是驱动开发中最容易写错、最容易导致崩溃的地方。

3.1 为什么绝对不能用memcpy?

很多新手会问:都是内存拷贝,为什么不能直接用memcpy?核心原因是用户态与内核态内存完全隔离

  1. 地址空间不同:用户态地址是虚拟地址,每个进程独立;内核态地址是全局虚拟地址
  2. 权限检查:用户态地址可能无效、未映射、没有读写权限,直接访问会触发内核Oops崩溃
  3. 安全防护copy_to/from_user会做完整的地址合法性检查和权限校验,防止恶意攻击

一句话总结:memcpy是同一个地址空间内的内存拷贝;copy_to/from_user是两个隔离地址空间之间的安全拷贝。

3.2 函数原型与使用场景

1. copy_to_user
unsignedlongcopy_to_user(void__user*to,constvoid*from,unsignedlongn);
  • 作用:将内核态数据拷贝到用户态缓冲区
  • 使用场景:驱动的read()函数中,把硬件数据/内核数据返回给用户态应用
  • 参数
    • to:用户态缓冲区指针(必须加__user标记)
    • from:内核态缓冲区指针
    • n:要拷贝的字节数
  • 返回值:成功返回0,失败返回未拷贝成功的字节数
2. copy_from_user
unsignedlongcopy_from_user(void*to,constvoid__user*from,unsignedlongn);
  • 作用:将用户态数据拷贝到内核态缓冲区
  • 使用场景:驱动的write()函数中,接收用户态应用发送的数据
  • 参数
    • to:内核态缓冲区指针
    • from:用户态缓冲区指针(必须加__user标记)
    • n:要拷贝的字节数
  • 返回值:成功返回0,失败返回未拷贝成功的字节数

3.3 必守的5条使用规则

  1. 必须加__user标记:标记用户态地址,告诉编译器这是用户态指针,做额外的安全检查
  2. 必须检查返回值:返回值>0表示有部分数据未拷贝成功,必须返回-EFAULT错误
  3. 必须校验缓冲区大小:防止用户态传入过大的长度,导致内核缓冲区溢出
  4. 不能在中断上下文使用:这两个函数可能睡眠,只能在进程上下文使用
  5. 不能拷贝内核地址到内核地址:同一个地址空间用memcpy,不要用这两个函数

3.4 常见崩溃问题与修正方案

❌ 问题1:忘记加__user标记

错误写法

staticssize_tchr_read(structfile*file,char*buf,size_tlen,loff_t*offset)

正确写法

staticssize_tchr_read(structfile*file,char__user*buf,size_tlen,loff_t*offset)

后果:编译警告,运行时可能触发内核Oops崩溃。

❌ 问题2:不检查返回值

错误写法

copy_to_user(buf,kernel_buf,len);// 不检查返回值returnlen;

正确写法

intret=copy_to_user(buf,kernel_buf,len);if(ret>0){printk(KERN_ERR"拷贝失败,未拷贝%d字节\n",ret);return-EFAULT;}returnlen-ret;

后果:用户态地址无效时,内核直接崩溃。

❌ 问题3:缓冲区越界

错误写法

// 直接用用户传入的len,不检查内核缓冲区大小copy_from_user(kernel_buf,buf,len);

正确写法

size_twrite_len=min(len,(size_t)BUF_SIZE);copy_from_user(kernel_buf,buf,write_len);

后果:内核缓冲区溢出,覆盖其他内核数据,导致系统崩溃。

❌ 问题4:直接访问用户态指针

错误写法

// 直接解引用用户态指针charc=*buf;

正确写法

charc;if(copy_from_user(&c,buf,1))return-EFAULT;

后果:用户态地址无效时,内核直接Oops崩溃。


4. 静态vs动态设备号:优缺点对比+现代推荐写法

4.1 核心对比表

对比项静态设备号动态设备号
分配方式开发者手动指定主设备号内核自动分配空闲的主设备号
核心APIregister_chrdev_region()alloc_chrdev_region()
优点设备号固定,方便管理不会冲突,自动管理,兼容性好
缺点容易和其他驱动冲突,需要提前申请设备号不固定,每次加载可能不同
适用场景内核自带的标准驱动(如串口、硬盘)所有第三方驱动、自定义驱动
现代推荐度⭐⭐⭐⭐⭐⭐⭐

4.2 两种写法代码示例

1. 静态设备号写法(不推荐)
// 手动指定主设备号,注意不要和系统已有的冲突#defineSTATIC_MAJOR200#defineSTATIC_MINOR0dev_num=MKDEV(STATIC_MAJOR,STATIC_MINOR);ret=register_chrdev_region(dev_num,1,DEV_NAME);if(ret<0){printk(KERN_ERR"静态注册设备号失败\n");returnret;}
2. 动态设备号写法(推荐)
// 内核自动分配空闲的主设备号,次设备号从0开始ret=alloc_chrdev_region(&dev_num,0,1,DEV_NAME);if(ret<0){printk(KERN_ERR"动态申请设备号失败\n");returnret;}printk(KERN_INFO"分配到的设备号:主=%d,次=%d\n",MAJOR(dev_num),MINOR(dev_num));

4.3 现代驱动最佳实践

  1. 优先使用动态设备号:这是Linux内核社区推荐的标准写法,避免设备号冲突问题
  2. 不要硬编码主设备号:硬编码的主设备号在不同系统、不同内核版本上很容易冲突
  3. 使用udev自动管理设备文件:通过class_createdevice_create自动生成/dev/设备文件,不需要手动mknod
  4. 静态设备号仅用于内核标准驱动:只有内核自带的、已经分配了固定主设备号的驱动才使用静态方式

5. 核心总结+面试必背考点

核心总结

  1. 主设备号标识驱动类型,次设备号标识同类型下的不同具体设备
  2. cdev结构体是字符设备的抽象,把设备号和驱动的操作函数集关联起来
  3. 字符设备注册流程:申请设备号 → 初始化cdev → 注册cdev → 创建设备类 → 生成设备文件
  4. copy_to/from_user是用户态与内核态数据交互的唯一安全方式,必须严格遵守使用规则
  5. 现代驱动优先使用动态设备号,避免设备号冲突,兼容性更好

面试必背考点

  1. 主设备号和次设备号的作用是什么?如何合成和提取?
  2. cdev结构体的作用是什么?字符设备注册的完整流程是什么?
  3. 为什么不能用memcpy在用户态和内核态之间拷贝数据?
  4. copy_to_usercopy_from_user的返回值是什么含义?
  5. 静态设备号和动态设备号的优缺点是什么?现代驱动推荐用哪种?
  6. 驱动的错误处理为什么要用goto?有什么好处?
  7. file_operations结构体中.owner = THIS_MODULE的作用是什么?
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/8 16:35:57

CE修改器新手必看:如何一键保存你找到的变量地址,告别重复扫描

CE修改器效率革命&#xff1a;变量地址保存与管理的终极指南 刚接触CE修改器的新手总会遇到这样的困境——每次重启目标程序后&#xff0c;之前辛苦扫描到的变量地址全都失效&#xff0c;不得不重复繁琐的扫描过程。这种低效的工作流不仅浪费时间&#xff0c;更消磨学习热情。本…

作者头像 李华
网站建设 2026/5/8 16:35:40

奇点大会照片里的时间密码:为什么这8张合影暴露了2024算力革命临界点?(附GPU集群部署时效对比数据表)

更多请点击&#xff1a; https://intelliparadigm.com 第一章&#xff1a;奇点智能技术大会现场照片分享 现场实拍与沉浸式体验 本届奇点智能技术大会在杭州云栖小镇国际会展中心举行&#xff0c;主会场采用全息投影AR导览系统&#xff0c;参会者通过官方App扫描展台即可调出…

作者头像 李华
网站建设 2026/5/8 16:35:34

音频产品开发:从DSP到MCU的演进与快速原型设计实践

1. 音频设计领域的现状与挑战看到“全球只有11位真正的音频设计师&#xff1f;”这个标题&#xff0c;你的第一反应是不是和我一样&#xff0c;觉得这简直是个天方夜谭&#xff1f;我最初也是这么想的&#xff0c;直到我深入了解了音频产品开发这个看似熟悉、实则壁垒森严的领域…

作者头像 李华
网站建设 2026/5/8 16:35:15

终极免费MP4视频修复指南:使用Untrunc恢复损坏的视频文件

终极免费MP4视频修复指南&#xff1a;使用Untrunc恢复损坏的视频文件 【免费下载链接】untrunc Restore a damaged (truncated) mp4, m4v, mov, 3gp video. Provided you have a similar not broken video. 项目地址: https://gitcode.com/gh_mirrors/unt/untrunc 你是否…

作者头像 李华