ARM《10》_01_字符设备驱动基础、学习开发字符驱动内核程序、总结规律和模板

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_字符设备驱动基础、学习开发字符驱动内核程序、总结规律和模板 - 鹿快
其中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_字符设备驱动基础、学习开发字符驱动内核程序、总结规律和模板 - 鹿快
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”,避免冲突。

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容