0、前言:
这是一篇以问题为导向,的技术贴!学习 字符设备驱动开发 的基础知识实现简单的基础案例;
基础概念库:
一、内核驱动相关知识概览:

二、linux内核驱动开发基础知识
【很重要】Linux 内核主要遵循 C89(ANSI C,1989 年制定的 C 标准)编程规范,对 C99 及以上标准的特性支持有限,若使用 C99 特有的语法,很可能会触发编译(make)错误。这是内核开发的一个重要约定,主要由历史原因和兼容性需求决定。
1、Linux 内核中驱动的分类:
块设备驱动:用于管理以“块”为单位读写数据的设备,数据块大小一般是512字节或更大,例如硬盘、u盘、sd卡,光盘等;支持随机访问,内核会提供缓存机制;如果涉及存储开发,需要学习;网络设备驱动:用于管理实现网络通信的设备,不直接对应文件系统中的节点,而是通过网络协议栈工作,例如以太网网卡、蓝牙模块、4G/5G模组等;数据传输单位是“帧”,需要处理网络协议(TCP、IP)的封装和解封装,工作机制和其他两类差异比较大;从事嵌入式网络开发、网关设备或无线通信设备开发时,该类型驱动是核心技能。字符设备驱动【适合入门】 :应用场景是串口、按键、LED、ADC、传感器等大量常用外设都属于字符设备,学习后能快速对接实际硬件;字符设备驱动涉及的内核 API(如设备号申请、file_operations 结构体、中断处理),是学习其他类型驱动的基础。
2、linux中字符设备驱动基础知识点:
linux系统中设备文件和普通文件的区别:
①、普通文件(Regular File)本质:存储实际数据的文件(文本、二进制、图片、程序等),数据以字节流形式保存在磁盘、分区等存储介质中。存在形式:真实占用磁盘空间,内容由用户或程序写入,删除后空间会被释放;
②、设备文件(Device File)本质:用户空间与硬件设备(或内核虚拟设备)交互的接口,不存储实际数据,而是通过文件操作(read/write等)映射到设备驱动的功能。 存在形式:通常位于 /dev 目录下,不占用磁盘空间(仅占用一个 inode 节点记录元信息),删除后重新创建设备节点即可恢复功能。 典型场景:/dev/sda(硬盘设备)、/dev/ttyS0(串口设备)、/dev/null(虚拟空设备)、/dev/zero(虚拟零设备)等。
在Linux系统中一切皆是文件,字符设备亦如此。 在/dev目录下可以看到很多创建好的设备节点,如下图所示:

字符设备基础概念:
1、字符设备:按字节流顺序读写的设备(如虚拟设备、串口等),对应文件系统中的一个设备节点(如/dev/mydevice)。
2、设备号:每个字符设备由 “主设备号(major)+ 次设备号(minor)” 唯一标识,主设备号标识驱动类型,次设备号标识具体设备。
3、file_operations 结构体:驱动与用户空间交互的 “桥梁”,里面定义了 read/write/open/close 等操作的函数指针。
3、字符设备核心结构体
1、struct cdev:字符设备的核心结构体,用于绑定设备操作函数并注册到内核,包含owner(所属模块)、ops(指向file_operations)等成员。2、file_operations:驱动与用户空间交互的 “函数表”,核心成员包括:
open/release:设备打开 / 关闭时的回调(如初始化资源、统计打开次数)。read/write:用户读写设备时的回调(需用copy_to_user/copy_from_user完成内核与用户空间的数据拷贝)。unlocked_ioctl:提供设备控制接口(如设置设备参数,需处理命令码)。owner:通常设为THIS_MODULE,防止模块在被使用时卸载。
4、字符设备的设备号管理
设备号的组成:
主设备号(major):标识驱动类型(如串口驱动共用一个主设备号),范围 0~255(动态分配可更大)。次设备号(minor):标识同一驱动管理的多个设备(如/dev/ttyS0和/dev/ttyS1的次设备号为 0 和 1)。
设备号类型dev_t:通过MKDEV(major, minor)组合主 / 次设备号,MAJOR(dev_t)/MINOR(dev_t)拆分。设备号的申请与释放
静态申请:register_chrdev_region(devno, count, name)(指定主设备号,需确保不冲突)。动态分配:alloc_chrdev_region(&devno, first_minor, count, name)(推荐,内核自动分配主设备号)。释放:unregister_chrdev_region(devno, count)(模块退出时必须调用,避免资源泄漏)。
5、设备注册与注销
字符设备的注册流程:申请设备号(register_chrdev_region或alloc_chrdev_region)。初始化cdev:cdev_init(&my_cdev, &fops)(绑定file_operations)。注册到内核:cdev_add(&my_cdev, devno, count)(count为设备数量)。注销流程:从内核移除设备:cdev_del(&my_cdev)。释放设备号:unregister_chrdev_region(devno, count)(与注册时的count一致)。
6、用户空间交互
数据拷贝:内核空间与用户空间严格隔离,必须通过内核提供的函数拷贝数据:
copy_to_user(dst, src, n):从内核空间(src)拷贝n字节到用户空间(dst),返回 0 表示成功。copy_from_user(dst, src, n):从用户空间(src)拷贝n字节到内核空间(dst),返回 0 表示成功。
禁止直接访问用户空间指针(可能因内存分页或权限导致崩溃)。
设备节点:用户空间通过/dev目录下的设备节点访问设备,需用mknod创建:
sudo mknod /dev/xxx c major minor(c表示字符设备)。自动创建设备节点:通过udev规则(用户空间)或内核class机制(class_create/device_create),避免手动执行mknod。
★7、开发流程:

三、linux系统分层
1、概念解释:
应用层(用户态):
Linux 中所有 ELF 可执行、动态库、解释器、容器进程都跑在用户空间,使用glibc/musl封装的 POSIX API,通过 syscall 指令陷入内核;它们只能看到自己的虚拟地址空间,任何对硬件或全局资源的访问都必须经过系统调用。系统调用层(内核态):
Linux 内核源码里的 kernel/entry/ 与 include/linux/syscalls.h 提供的系统调用入口集合,共 400 余条(如 sys_read、sys_open),用户态执行 syscall 指令后 CPU 切换到 Ring 0,先到达该层做寄存器保存、参数检查、内核栈切换,再分发到对应的内核子系统;执行完毕把返回值写入 rax 并 sysret 回到用户态。内核(内核态):
即 Linux 内核本身,包括进程调度器(CFS)、内存管理(slab、页表、反向映射)、虚拟文件系统(VFS)、网络协议栈(TCP/IP、Netfilter)、设备驱动、中断/异常处理、cgroups、seccomp 等,所有代码运行在内核空间,可直接访问物理地址和设备寄存器,系统调用层是其对外暴露的唯一受控接口。
2、类比说明:把 Linux 想象成一座办公大厦
应用层 → 普通员工在自己的工位(用户空间)干活,想打印、开空调、订会议室,只能填“服务申请单”(系统调用)。系统调用层 → 前台接待(大厦唯一对外窗口),负责验单、登记、刷卡,把人或请求送进后厨机房,本身不碰打印机/空调。内核层 → 物业维修队 + 机房 + 配电室,真正接电线、换墨盒、调温度,再把结果送回前台,前台再转给员工。
四、内核中常见错误码:
错误码的返回形式:驱动中需返回 “负的错误码”(如 -EINVAL),内核会自动转换为用户空间的 errno(正数)。

具体问题解决:
案例 1:极简虚拟字符设备(入门)
跑通 “模块加载→设备注册→用户访问” 的完整流程,建立驱动基础认知。加载驱动时自动申请设备号、注册 cdev;支持open/close操作,记录设备被打开的次数(通过dmesg查看);用户通过ls /dev/xxx确认设备节点,用cat/echo测试(虽无读写逻辑,但能验证设备存在)。用内核内存数组模拟 “硬件存储区”,无需真实存储芯片。检查是否已安装内核源码或内核头文件(用于编译模块),若存在则正常;否则安装。

有基本的编译工具(gcc、make);
first.c文件
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h> // file_operations结构体
#include <linux/cdev.h> // 字符设备结构体
// 设备名称(自定义,用于标识设备)
#define DEV_NAME "myfirstdev"
// 全局变量
static int major = 0; // 主设备号(0表示自动分配)
static int open_count = 0; // 记录设备打开次数
static struct cdev my_cdev; // 字符设备结构体
// 1. 实现open函数(用户打开设备时调用)
static int mydev_open(struct inode *inode, struct file *filp) {
open_count++; // 每次打开,计数+1
// 内核打印(用KERN_INFO级别,dmesg可查看)
printk(KERN_INFO "[myfirstdev] open: 第%d次打开设备
", open_count);
return 0; // 成功返回0
}
// 2. 实现release函数(用户关闭设备时调用)
static int mydev_release(struct inode *inode, struct file *filp) {
printk(KERN_INFO "[myfirstdev] release: 设备已关闭
");
return 0; // 成功返回0
}
// 3. 定义文件操作集合(绑定open/release)
static struct file_operations mydev_fops = {
.owner = THIS_MODULE, // 所属模块(防止模块被意外卸载)
.open = mydev_open, // 绑定open函数
.release = mydev_release // 绑定release函数
};
// 定义入口函数
static int __init my_cd1_init(void)
{
int ret;
dev_t devno; // 设备号(主+次)
// 步骤A:申请设备号(自动分配)
// 参数:设备号指针、起始次设备号、设备数量、设备名称
ret = alloc_chrdev_region(&devno, 0, 1, DEV_NAME);
if (ret < 0) { // 申请失败
printk(KERN_ERR "[myfirstdev] 设备号申请失败!
");
return ret; // 退出初始化
}
// 从设备号中提取主设备号(后续创建设备节点需要)
major = MAJOR(devno);
printk(KERN_INFO "[myfirstdev] 设备号申请成功:主设备号=%d
", major);
// 步骤B:初始化字符设备(绑定文件操作)
cdev_init(&my_cdev, &mydev_fops);
my_cdev.owner = THIS_MODULE; // 指定所属模块
// 步骤C:注册字符设备到内核
// 参数:字符设备结构体、设备号、设备数量
ret = cdev_add(&my_cdev, devno, 1);
if (ret < 0) { // 注册失败
printk(KERN_ERR "[myfirstdev] 字符设备注册失败!
");
// 失败时释放已申请的设备号
unregister_chrdev_region(devno, 1);
return ret;
}
printk(KERN_INFO "[myfirstdev] 驱动初始化成功!
");
return 0;
}
// 定义出口函数
static void __exit my_cd1_exit(void)
{
dev_t devno = MKDEV(major, 0); // 组合设备号(主设备号+次设备号0)
// 步骤1:从内核注销字符设备
cdev_del(&my_cdev);
// 步骤2:释放设备号
unregister_chrdev_region(devno, 1);
printk(KERN_INFO "[myfirstdev] 驱动已卸载!
");
}
// 声明入口函数和出口函数(内核规定的宏)
module_init(my_cd1_init);
module_exit(my_cd1_exit);
// 模块描述信息(必选,否则内核报警告)
MODULE_LICENSE("GPL"); // 许可证(必须为GPL,否则内核污染)
MODULE_AUTHOR("Your Name"); // 作者(自定义)
MODULE_DESCRIPTION("My First Char Device Driver"); // 描述
open_count是我们自己定义为了方便看到每次打开会被计数,不设置,程序也是对的
Makefile文件
# 内核源码路径(自动获取当前系统内核的编译目录)
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
# 当前目录(驱动代码所在路径)
PWD := $(shell pwd)
# 要编译的模块:first.ko(由first.c生成)
obj-m += first.o
# 编译命令(默认执行)
all:
make -C $(KERNELDIR) M=$(PWD) modules
# 清理编译产生的文件
clean:
make -C $(KERNELDIR) M=$(PWD) clean
下面这种make报错,往往是Makefile中 tab 分隔符不对,可能是几个空格,修改成tab即可;

测试验证:
验证加载驱动

验证创建设备节点(用户空间访问入口):创建设备节点(用户空间访问入口)字符设备需要在/dev目录下创建一个 “设备节点”,用户才能通过文件操作访问:【在没有真实硬件的情况下,mknod 手动创建设备节点的本质就是用软件模拟一个 “硬件访问入口”,让用户空间程序能像操作真实硬件一样与驱动交互。】
![图片[1] - ARM《10》_01_字符设备驱动基础、学习开发字符驱动内核程序、总结规律和模板 - 鹿快](https://img.lukuai.com/blogimg/20251107/7d1996b0ef28422d9c09b6921046373d.png)
其中c表示创建字符驱动设备,237表示主设备号,0表示次设备号;
创建设备节点的意义:
硬件设备的访问被抽象为对 “设备文件(节点)” 的操作。但设备节点本身不对应任何真实硬件,它的作用是:建立映射关系:通过 “主设备号 + 次设备号” 关联到内核中对应的驱动程序(比如主设备号 237 对应我们编写的first驱动) 提供访问接口:用户空间程序(如cat、echo或自定义 C 程序)通过/dev/myfirstdev这个路径,调用open/close等系统调用,最终触发驱动中对应的函数。设备节点在这里的作用就像一个 “桥梁”:用户空间通过/dev/myfirstdev发起操作(如cat打开设备);内核根据节点的设备号找到对应的驱动程序;驱动执行预设的软件逻辑(如open时计数 + 1),无需真实硬件参与。如果是真实硬件(比如开发板上的 LED),驱动中会包含操作硬件的代码(如写 GPIO 寄存器控制 LED 亮灭),但设备节点的作用依然不变—— 它还是用户空间访问硬件的入口,只是驱动的底层逻辑从 “软件模拟” 变成了 “硬件操作”。
测试驱动功能,测试设备的open和close操作,用cat命令打开设备(会触发open,然后立即close):
![图片[2] - ARM《10》_01_字符设备驱动基础、学习开发字符驱动内核程序、总结规律和模板 - 鹿快](https://img.lukuai.com/blogimg/20251107/c1265a7f70f74dc79fbcbbf64f235506.png)
cat命令的逻辑是:open设备 → 调用read读取数据 → close设备。这里驱动只实现了open和release,但没有实现file_operations中的read函数(默认值为NULL)。当内核发现read函数为NULL时,会返回 “无效的参数” 错误(这是内核的默认行为)。
测试驱动功能,多次执行cat命令,观察open_count递增:

测试完成后,要记得卸载模块并清理设备节点:因为Linux 内核模块直接运行在内核态,异常的模块加载或残留的设备节点,可能导致 资源泄漏甚至系统崩溃,所以测试后必须规范清理。

案例 2:带内存缓冲区的虚拟设备(基础)
案例 2 是在案例 1(仅实现open/close的虚拟设备)基础上,增加用户空间与内核空间的数据交互核心功能(比如实现read/write函数),更贴近真实字符设备的使用场景。掌握read/write实现与内核 – 用户数据交互,这是字符设备的核心能力。 在内核中创建 128字节 缓冲区,用户可通过echo “test” > /dev/xxx写入数据,cat /dev/xxx读取数据;支持连续读写(通过loff_t *f_pos更新文件指针,避免每次读都从开头开始);处理读写长度限制(如写入超过 1KB 时自动截断)。【用内核内存数组模拟 “硬件存储区”,无需真实存储芯片。】
t.c
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h> // 用于用户空间与内核空间数据拷贝
// 1. 定义全局变量(设备相关)
#define DEV_NAME "mysecondev" // 设备名
#define DEV_NUM 1 // 设备数量
#define BUF_SIZE 128 // 数据缓冲区大小(新增)
static dev_t devno; // 设备号
static struct cdev my_cdev; // 字符设备结构体
static int open_count = 0; // 打开次数计数器(案例1保留)
static char dev_buf[BUF_SIZE]; // 数据缓冲区(新增:存储用户读写的数据)
static size_t data_len = 0; // 新增:记录用户实际写入的有效数据长度
// 2. 实现文件操作函数(新增read/write)
// 读函数:内核→用户(用户cat设备节点时触发)
static ssize_t mydev_read(struct file *file, char __user *buf, size_t count, loff_t *ppos)
{
unsigned long ret;
size_t real_copy_len;
size_t start_pos; // 新增:记录本次读取的起始位置
if (*ppos >= data_len) {
printk(KERN_INFO "read: no more data (ppos=%lld, data_len=%ld)
", (long long)*ppos, data_len);
return 0;
}
real_copy_len = data_len - *ppos;
if (real_copy_len > count) {
real_copy_len = count;
}
start_pos = *ppos; // 读取前记录起始位置
ret = copy_to_user(buf, dev_buf + start_pos, real_copy_len);
if (ret > 0) {
printk(KERN_ERR "read: copy failed, uncopy len=%lu
", ret);
return -EFAULT;
}
*ppos += real_copy_len; // 更新偏移量
// 用start_pos打印正确的数据
printk(KERN_INFO "read: success! len=%ld, data=[%.*s]
",
real_copy_len,
(int)real_copy_len,
dev_buf + start_pos);
return real_copy_len;
}
// 写函数:用户→内核(用户echo数据到设备节点时触发)
static ssize_t mydev_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos)
{
// 变量声明移到开头(解决警告)
unsigned long ret;
size_t write_len;
size_t i;
// 1. 计算最大写入长度(不超过缓冲区)
write_len = (count > BUF_SIZE) ? BUF_SIZE : count;
// 2. 先拷贝用户数据到内核缓冲区(必须先拷贝,再检查内容)
ret = copy_from_user(dev_buf, buf, write_len);
if (ret > 0) {
printk(KERN_ERR "write: copy failed, uncopy len=%lu
", ret);
return -EFAULT;
}
// 3. 检查内核缓冲区的最后一个字符是否是换行符(安全访问内核地址)
data_len = write_len; // 先默认有效长度是拷贝长度
if (data_len > 0 && dev_buf[data_len - 1] == '
') {
data_len--; // 去掉换行符,有效长度减1
}
// 4. 打印日志(修复for循环语法)
printk(KERN_INFO "write: success! len=%ld, data_hex=[", data_len);
for (i = 0; i < data_len; i++) {
printk("%02x ", (unsigned char)dev_buf[i]);
}
printk("], data_str=[%.*s]
", (int)data_len, dev_buf);
return write_len; // 返回实际拷贝的字节数(用户端关心的是“写了多少”)
}
// 打开函数(案例1保留,新增缓冲区清空)
static int mydev_open(struct inode *inode, struct file *file)
{
open_count++;
// 核心逻辑:如果当前没有有效数据(data_len=0),则清空缓冲区;否则保留数据
if (data_len == 0) {
memset(dev_buf, 0, BUF_SIZE); // 初始状态/无数据时,清空避免垃圾字符
printk(KERN_INFO "open: buffer cleared (no existing data)
");
} else {
printk(KERN_INFO "open: keeping existing data (len=%ld)
", data_len); // 有数据时保留
}
printk(KERN_INFO "open: success! open count=%d
", open_count);
return 0;
}
// 关闭函数(案例1保留)
static int mydev_release(struct inode *inode, struct file *file)
{
printk(KERN_INFO "release: success!
");
return 0;
}
// 3. 定义文件操作集合(新增read/write绑定)
static struct file_operations mydev_fops = {
.owner = THIS_MODULE, // 模块拥有者(必设)
.open = mydev_open, // 绑定打开函数
.release = mydev_release,// 绑定关闭函数
.read = mydev_read, // 绑定读函数(新增)
.write = mydev_write // 绑定写函数(新增)
};
// 4. 模块加载函数(设备初始化+注册,案例1逻辑保留)
static int __init mydev_init(void)
{
int ret;
// 步骤1:申请设备号
ret = alloc_chrdev_region(&devno, 0, DEV_NUM, DEV_NAME);
if (ret < 0) {
printk(KERN_ERR "alloc_chrdev_region failed, ret=%d
", ret);
return ret;
}
printk(KERN_INFO "alloc devno success! major=%d, minor=%d
", MAJOR(devno), MINOR(devno));
// 步骤2:初始化字符设备(绑定fops)
cdev_init(&my_cdev, &mydev_fops);
my_cdev.owner = THIS_MODULE; // 设为当前模块拥有者
// 步骤3:注册字符设备到内核
ret = cdev_add(&my_cdev, devno, DEV_NUM);
if (ret < 0) {
printk(KERN_ERR "cdev_add failed, ret=%d
", ret);
unregister_chrdev_region(devno, DEV_NUM); // 注册失败,释放设备号
return ret;
}
printk(KERN_INFO "mydev init success!
");
return 0;
}
// 5. 模块卸载函数(设备注销,案例1逻辑保留)
static void __exit mydev_exit(void)
{
// 步骤1:从内核注销字符设备
cdev_del(&my_cdev);
// 步骤2:释放设备号
unregister_chrdev_region(devno, DEV_NUM);
printk(KERN_INFO "mydev exit success!
");
}
// 6. 模块入口/出口声明
module_init(mydev_init);
module_exit(mydev_exit);
// 模块许可声明(必设,否则编译警告)
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("My Second Char Device Driver");
MODULE_AUTHOR("Your Name");
Makefile文件
# 其他和案例1一致;
# 修改要编译的模块:first1.ko(由first1.c生成)
obj-m += first1.o
测试验证
编译生成.ko文件;
测试用户向内核写数据,我把前面的myfirstdev从dev中删除了,重新用maknod创建的与案例二适配的myfirstdev,在执行之前,我用cat打开了2次了,所以下面会显示open count=3:

测试用户从内核读数据:【在这改了很长时间bug,因为hello打印不出来,原因是代码中的打开函数当中,每次会清空缓存区】

最后记得删除硬件节点、卸载内核驱动;

小结
案例1 小结
1、创建字符驱动内核程序的过程
字符设备驱动的编写主要是通过结构体 file_operations 实现驱动被调用时的各种操作,对于其中函数的定义写法也是固定的;对于驱动入口函数中字符设备的初始化流程也是固定的,先申请设备号,由内核分发给“设备号指针”;然后初始化字符设备,就是把字符设备结构体和文件操作结构体绑定;最后注册字符设备到内核当中,绑定我们自定义的字符设备结构体和申请的设备号,实现“设备节点到内核驱动程序的映射”;最后在这个内核模块的出口函数中,先注销字符设备,再释放设备号,这个过程和创建字符设备的过程相反。

2、用户调用字符驱动的流程
用户空间通过设备节点发起操作 → 内核空间驱动接收并处理 → 驱动与 “虚拟硬件”(内核内存中的软件模拟逻辑)交互 → 结果返回用户空间。

3、在字符设备驱动中体现的linux中FVS的概念总结:
在第一个案例中,创建的 字符设备结构体 和 文件操作集合结构体 是两种没有关联的结构体,可以理解为,文件操作集合结构体是内核驱动程序对linux中FVS概念的具体实现,也是linux中万物皆文件的一种体现,而字符设备结构体和文件操作集合结构体的绑定是依赖 字符设备框架 实现的。
4、缩写
dev 是 device 的缩写,直译就是“设备”。alloc_chrdev_region(&devno, 0, 1, DEV_NAME) :allocate → “分配”、region → “区域/范围”。
案例2 小结
1、必须先加载驱动模块(完成设备注册),获得设备号,再创建设备节点,VFS 才能正确识别并关联设备节点与驱动程序。
1、设备节点(如 /dev/mysecondev)本身只是一个特殊的文件,它的核心作用是存储设备号(主设备号 + 次设备号)(记录在节点的inode结构体中),并不直接包含驱动逻辑。2、驱动加载时(insmod)会做两件事:①、通过alloc_chrdev_region向内核申请并分配设备号(主设备号由内核动态分配,次设备号从 0 开始);②、通过cdev_add将设备号与驱动的file_operations(操作逻辑)绑定并注册到内核字符设备框架(加入全局字符设备链表)。3、当用户访问设备节点时(如echo “test” > /dev/mysecondev),VFS 的处理流程是:①、解析节点的inode,提取存储的设备号;②、调用字符设备框架接口,通过设备号在全局字符设备链表中查找对应的struct cdev;③、找到后,调用cdev绑定的file_operations函数(如mydev_write)。
2、写函数触发方式和执行流程
注意下面示意图中,应用层在用户态,系统调用层和内核层都在内核态;

2、读函数触发方式和执行流程
流程和写函数类似

总结
总结1:linux内核开发中最基本的 <写函数> 和 <读函数> 模板
在保留 “安全传输 + 防错” 核心逻辑,后得到的最简模板如下:参数解释:
1、struct file *file:文件对象指针(内核定义的结构体),记录当前设备的 “打开状态信息”,比如设备的读写权限、打开次数、私有数据等。开发者无需修改该参数(避免内核异常),仅可读取状态;
2、const char __user *buf:用户空间缓冲区指针,__user 是内核标记宏。用户提供的 “数据发送缓冲区”,用户要写入的数据存放在这里,内核需从这里拷贝数据。__user 标记表明该地址属于用户空间,内核态绝对不能直接访问(如 buf[0] 取值、赋值),必须通过 copy_to_user(读)/copy_from_user(写)进行安全拷贝,否则会导致内核崩溃或安全问题。
3、size_t count: 用户请求的 “数据传输字节数” ,由用户程序指定(如 echo “abc” 会传入 count=4,包含末尾换行符)。不能直接用 count 作为拷贝长度,因为可能超过内核缓冲区大小,必须与缓冲区剩余空间比较,取最小值,避免缓冲区溢出。
4、 loff_t *ppos :长整型偏移量指针,loff_t 是内核定义的 “大整数类型”,支持 64 位偏移,记录设备的 “当前读写位置”(单位:字节),初始值为 0,相当于文件的 “光标”。必须手动更新偏移量(*ppos += 实际传输长度),否则下次读写会重复操作同一位置(如读函数一直返回开头数据,写函数一直覆盖开头数据);偏移量是用户可见的 “逻辑位置”,与内核缓冲区的物理地址无关。写函数模板
//作用:将用户空间数据安全拷贝到内核空间,响应用户的 “写设备” 操作
static ssize_t mydev_write(struct file *file,
const char __user *buf,
size_t count,
loff_t *ppos)
{
// 变量声明(C89要求:函数开头集中声明)
unsigned long ret; // 存储拷贝结果(未拷贝字节数)
size_t real_len; // 实际可传输字节数(防溢出),剩余缓存空间;
static char kbuf[128]; // 内核缓冲区(可自定义大小,最简示例用128字节)
// 1. 计算实际传输长度:取“用户请求长度”与“内核缓冲区剩余空间”最小值,这种是对于"追加写入的场景"设置的判断
real_len = sizeof(kbuf) - *ppos;
real_len = (real_len > count) ? count : real_len;
// count是用户实际写入的字节数;
if (real_len == 0) {
return -ENOSPC; // 缓冲区满,返回“无空间”错误码
}
// 2. 安全拷贝:用户空间 → 内核缓冲区(必用copy_from_user,不可直接访问buf)
ret = copy_from_user(kbuf + *ppos, buf, real_len);
if (ret > 0) {
return -EFAULT; // 拷贝失败,返回“坏地址”错误码
}
// 3. 更新偏移量:下次从当前位置继续写(支持追加写入)
*ppos += real_len;
// 4. 返回实际传输字节数(成功返回正数)
return real_len;
}
1、上面写函数模板是针对 追加写入 设计的,在追加写入时,ppos 每次都会变化,每次写入多少字节,ppos 就增加多少字节(即 *ppos += 实际写入的字节数)。这是实现 “追加” 的核心逻辑 —— 让下一次写入从当前位置的末尾开始,避免覆盖已有数据。
2、追加写入的应用场景很多典型的如,串口 / 传感器是 “持续流式发数据”,驱动需要先缓存数据,再交给应用程序读取。比如温湿度传感器每秒发 “25℃”“26℃”,追加写能把数据缓存为 “25℃26℃….”,应用程序读取时能拿到完整的时序数据;如果用覆盖写,只能拿到最后一次的 26℃,失去历史数据价值。
3、写函数的流程:用户请求→内核校验(校验count是否超出缓存区大小)→安全传输(copy_from_user)→状态更新(更新ppos)→反馈结果(实际写入数据大小)
读函数模板
static ssize_t mydev_read(struct file *file,
char __user *buf,
size_t count,
loff_t *ppos)
{
static char kbuf[128];
size_t total_written = *ppos; // 已写入的总长度(ppos的值)
// 实际可读长度:用户请求vs已写总长度
real_len = min(count, total_written);
if (real_len == 0) return 0; // 没写数据,返回0
// 从0开始读,拷贝real_len字节
copy_to_user(buf, kbuf, real_len);
// (可选)若支持重复读,ppos保持不变;若只读一次,可重置ppos=0
// *ppos = 0; // 按需开启
return real_len;
}
1、上面写函数模板是针对 顺序读取 设计的,
2、count 是用户调用 read() 时明确指定的 “想要读取的字节数”,一旦用户发起读取请求,这个值就固定了 —— 内核不会修改 count,只会根据 count 计算 “实际能读取的字节数(real_len)”。
3、读函数流程:用户请求→内核校验(ppos是否超出可读取范围,求出可读取值的数量)→安全传输(copy_to_user)→状态更新(更新ppos)→反馈结果(反馈读取值的数量)
总结2、关于ppos说明
1、对于一个设备节点读和写共用同一个 ppos :对于同一个设备节点,无论执行多少次读操作或写操作,都共享同一个 ppos 变量—— 它记录的是 “当前操作的逻辑位置”,读和写都会根据这个位置执行;
2、上述读和写的模板是无并发的情况(写完再读,读完再写,不被打断)下的写法举例;
3、如果遇到并发,就要小心处理ppos了;并发场景的解决ppos可能会被同时改写的问题,实际驱动中,简单的思路就是只需加一把 互斥锁(mutex) 即可保证 “同一时间只有一个操作(读或写)能访问缓冲区和 ppos”,避免冲突。















暂无评论内容