【嵌入式环境下linux内核及驱动学习笔记-(4-字符驱动实例)】

1、完整的字符设备驱动的模板

\qquad 用一个内存块做为假设的设备。字符设备驱动实现这个内存块的读写等操作。应用程序能够通过对应的设备文件实现对这个设备(内存块)的读写等操作。

2、相关函数

\qquad 在继续之前,必须先熟悉一下一些函数的作用和语法。

2.1 container_of()

已知成员的地址,要获得所在结构体变量的地址,使用这个宏: container_of(局部成员实际地址,结构体类型名,成员在结构体中的名称)

宏的定义如下:

#include <linux/kernel.h>

#define container_of(ptr, type, member) ({   \
 	const typeof(((type *)0)->member) * __mptr = (ptr); \
 	(type *)((char *)__mptr - offsetof(type, member)); })

解释:

  • 第一句 const typeof(((type *)0)->member) * __mptr = (ptr); 算出了这个局部成员变量的地址,给了_mptr。
  • 第二句offsetof(type,member)是计算出了member在type类型 中的偏移量(假设为s个byte)
    • (char *)__mptr是把这个地址强转成指向单个byte的地址,为了接下来计算地址量。
    • 接下来((char *)__mptr - offsetof(type, member))就是当前的这个局部成员的地址往回减s,就得出了局部成员变量所在的结构体的起始地址

2.2 kmalloc

原型:

#include <linux/slab.h>void *kmalloc(size_t size, gfp_t flags);

\qquad kmalloc() 申请的内存位于“直接映射区域”,而且在物理上也是连续的,它们与真实的物理地址只有一个固定的偏移,因为存在较简单的转换关系,所以对申请的内存大小有限制,不能超过128KB

较常用的 flags(分配内存的方法):

  • GFP_ATOMIC —— 分配内存的过程是一个原子过程,分配内存的过程不会被(高优先级进程或中断)打断;(异常上下文内用这个标志)
  • GFP_KERNEL —— 正常分配内存;(会阻塞,所以用于任务上下文)
  • GFP_DMA —— 给 DMA 控制器分配内存,需要使用该标志(DMA要求分配虚拟地址和物理地址连续)。

对应的内存释放函数为:

void kfree(const void *objp);
void *kzalloc(size_t size, gfp_t flags)      // kmalloc的近亲函数,分配并设0值

2.3 copy_to_user()

copy_to_user()函数用于内核空间向用户空间拷贝数据。它的原型如下:

#include <asm/uaccess.h>

unsigned long copy_to_user(void __user *to, const void *from, unsigned long n); 

参数解释:

  • to:指向用户空间缓冲区的指针。
  • from:指向内核空间源缓冲区的指针。
  • n:要拷贝的字节数。

返回值:

  • 返回值0:表示成功拷贝n个字节数据到内核空间。
  • 返回值大于0小于等于n:表示拷贝失败,返回值为未成功拷贝的字节数

这个函数主要在内核驱动中被使用,当驱动向用户空间传递数据时需要调用这个函数。例如一个读函数,它从设备读取数据到内核空间缓冲区,然后调用copy_to_user()将数据拷贝给用户提供的缓冲区。
示例:

//内核驱动代码 
ssize_t driver_read(char __user *buf, size_t count)
{
    char kernel_buf[100];
    unsigned long ret;
    
    //从设备读取数据到kernel_buf
    ......
    
    //将数据拷贝给用户空间buf
    ret = copy_to_user(buf, kernel_buf, count);
    if (ret > 0) {
        //拷贝失败
        return -EFAULT; 
    }
    
    return count;  //成功读取count个字节
}

这个函数需要注意几点:

  1. to指针指向的用户空间内存区域必须由用户进程分配和释放,内核不能直接访问用户空间内存。
  2. n表示内核空间要拷贝给用户空间的最大字节数。实际拷贝的字节数可能小于n,返回值表示实际拷贝的字节数。
  3. 如果返回值大于等于n,表示拷贝失败,需要返回错误码。
  4. 拷贝失败的原因可能是to指向的地址不合法,或者在拷贝过程中出现故障。
  5. from和to所指内存区域不能重叠。

2.4 copy_from_user()

copy_from_user()函数用于用户空间向内核空间拷贝数据。它的原型如下:

#include <asm/uaccess.h>

unsigned long copy_from_user(void *to, const void __user *from, unsigned long n); 

参数解释:

  • to:指向内核空间目标缓冲区的指针。
  • from:指向用户空间源缓冲区的指针。
  • n:要拷贝的字节数。

返回值:

  • 返回值0:表示成功拷贝n个字节数据到内核空间。
  • 返回值大于0小于等于n:表示拷贝失败,返回值为未成功拷贝的字节数
    这个函数也主要在内核驱动中被使用,当驱动需要获取用户空间数据传递到内核时需要调用这个函数。例如一个写函数,它调用copy_from_user()从用户空间获取数据,然后将数据写入设备。
    示例:
//内核驱动代码
ssize_t driver_write(const char __user *buf, size_t count)
{
    char kernel_buf[100];
    unsigned long ret;
    
    //从用户空间拷贝数据到kernel_buf
    ret = copy_from_user(kernel_buf, buf, count);
    if (ret > 0) {
        //拷贝失败
        return -EFAULT;
    }
    
    //将kernel_buf中的数据写入设备
    ......
    
    return count;  //成功写入count个字节
}

这个函数也需要注意几点:

  1. from指针指向的用户空间内存区域必须由用户进程分配和释放,内核不能直接访问用户空间内存。
  2. n表示要从用户空间拷贝到内核空间的最大字节数。实际拷贝的字节数可能小于n,返回值表示实际拷贝的字节数。
  3. 如果返回值大于等于n,表示拷贝失败,需要返回错误码。
  4. 拷贝失败的原因可能是from指向的地址不合法,或者在拷贝过程中出现故障。
  5. from和to所指内存区域不能重叠。

2.5 系统调用open()

在Linux内核中,open()系统调用用于打开一个文件或设备,它定义在<sys/syscall.h>头文件中。
原型为:

#include <fcntl.h>

int open(const char *pathname, int flags);

int open(const char *pathname, int flags, mode_t mode);

参数:

  • pathname指定要打开的文件或设备的路径名。
  • flags指定打开文件的方式和权限,
  • mode参数只在创建文件时(O_CREAT标志被设置)时生效,它指定文件的访问权限。
  1. open函数打开的文件有两种类型:
  • 普通文件:文件路径名指向的是一个普通文件,打开后可以读写等操作。
  • 特殊文件:文件路径名指向的不是一个普通文件,比如终端设备/dev/tty,管道/dev/pipe等。打开特殊文件后对它的读写代表与该设备的读写通信。
  1. flags参数表示以什么模式打开文件:
  • O_RDONLY:只读方式打开,调用open的进程可以读取该文件,但不能写入。
  • O_WRONLY:只写方式打开,调用进程可以写入该文件,但不能读取。
  • O_RDWR:读写方式打开,调用进程可以同时读写该文件。
  • O_APPEND:以追加方式打开,每次写入会追加到文件末尾。
  • O_CREAT:如果该文件不存在,就创建该文件。
  • O_EXCL:与O_CREAT配合使用,如果文件已存在,则返回错误。
  • O_TRUNC:如果文件已存在,并且以只写或读写方式打开,则先将文件长度截断为0。

  • 这些flags可以使用|操作符 " | " 组合使用。
  1. mode可以由以下宏组成(定义在<sys/stat.h>中):
  • S_IRUSR : 用户(User)读权限
  • S_IWUSR : 用户(User)写权限
  • S_IXUSR : 用户(User)执行权限
  • S_IRGRP : 用户组(Group)读权限
  • S_IWGRP : 用户组(Group)写权限
  • S_IXGRP : 用户组(Group)执行权限
  • S_IROTH : 其他(Other)读权限
  • S_IWOTH : 其他(Other)写权限
  • S_IXOTH : 其他(Other)执行权限
  1. mode参数仅在O_CREAT被指定时有效,它表示创建文件的访问权限。一般用0777。
  2. 成功打开返回文件描述符(大于等于0的整数),失败返回-1。
  3. 打开的文件需要调用close关闭。

示例代码:

#include <sys/stat.h>
#include <fcntl.h>

int main() {
    //只读方式打开文件
    int fd1 = open("file.txt", O_RDONLY);
    
    //只写方式打开文件,如果文件不存在则创建
    int fd2 = open("file.txt", O_WRONLY | O_CREAT, 0777);
    
    //读写方式打开文件,如果文件已存在则先清空内容
    int fd3 = open("file.txt", O_RDWR | O_TRUNC);
    
    //读写方式打开文件,如果文件不存在则创建,且只能由调用进程打开
    int fd4 = open("file.txt", O_RDWR | O_CREAT | O_EXCL, 0777);

    //.........
    
    close(fd1);
    close(fd2);
    //......
}

2.6 系统调用read

read函数用于从已打开的文件描述符fd读取数据。它的原型如下:

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);

参数解释:

  • fd:文件描述符,由open函数返回。
  • buf:指向用户缓冲区的指针,用于存放读取的数据。
  • count:要读取的最大数据量(以字节为单位)。

返回值:

  • 成功读取的字节数,返回count或可能小于count。
  • 到达文件末尾,返回0
  • 非阻塞IO,无数据可读,返回-EAGAIN
  • 遇到信号中断,返回读取的字节数,可以重新调用读取剩余数据
  • 其他错误,返回负错误号
  • -1 表示读取失败。

示例:

#include <unistd.h>

int main() {
    int fd = open("file.txt", O_RDONLY);
    char buf[100];
    ssize_t count;
    
    //读取100个字节到buf,并获取实际读取的字节数
    count = read(fd, buf, 100);
    //使用buf中的数据.........
    
    //再次读取100个字节,如果文件结尾则返回0
    count = read(fd, buf, 100);
    //.......
    
    close(fd);
}

read函数在读取文件时有几点需要注意:

  1. read读取的是文件内容,不是字符串,所以读取的数据不是以’\0’结束。
  2. 读取的数据量可能小于请求的count,这时只获取实际读取的字节数。需要重复调用read直到返回0,表示读取完毕。
  3. read读取文件时,文件读取指针会动。所以下次读取会从上次结束的地方开始。可以使用lseek函数改变读取指针的位置。
  4. 对于特殊文件(管道,终端等)的读取,read获取的数据来自该特殊文件对应设备的输入。
  5. 文件描述符fd需要是有效的,对已经close的文件调用read会导致错误。

2.7 系统调用write

write函数用于向已打开的文件描述符fd写入数据。它的原型如下:

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

参数解释:

  • fd:文件描述符,由open函数返回。
  • buf:指向用户缓冲区的指针,该缓冲区包含要写入的数据。
  • count:要写入的最大数据量(以字节为单位)。

返回值:

  • 成功写入count个字节数据,则返回count
  • 磁盘空间已满或达到文件大小限制,返回写入的字节数
  • 遇到信号中断,返回已写入的字节数,可以重新调用写入剩余数据
  • 其他错误,返回负错误号
  • -1 表示写入失败。

示例:

#include <unistd.h>

int main() {
    int fd = open("file.txt", O_WRONLY | O_CREAT, 0777);
    char *buf = "Hello World!";
    ssize_t count;
    
    //写入12个字节到文件
    count = write(fd, buf, 12);
    
    //再次写入,如果文件不够大可能不会写入12个字节
    count = write(fd, buf, 12); 
    
    close(fd);
}

write函数在写入文件时有几点需要注意:

  1. write写入的不是字符串,所以写入的数据不会自动追加’\0’结束符。
  2. 写入的数据量可能小于请求的count,这时只会写入实际的字节数。需要重复调用write直到写入全部数据。
  3. write写入文件时,文件写入指针会动。所以下次写入会从上次结束的地方开始。可以使用lseek函数改变写入指针的位置。
  4. 对于特殊文件(管道,终端等)的写入,write提供的数据会输出到该特殊文件对应设备的输出。
  5. 文件描述符fd需要是有效的,对已经close的文件调用write会导致错误。

2.8 系统调用close

close()函数用于关闭一个已打开的文件描述符。它的原型如下:

#include <unistd.h>

int close(int fd);

参数

  • fd表示要关闭的文件描述符。

返回值:

  • 成功返回0
  • 失败返回-1,并设置errno

这个函数通常用在文件操作完成后,关闭打开的文件。例如:

int fd = open("file.txt", O_RDONLY);
// do something with fd
close(fd);

close()函数需要注意以下几点:

  1. fd必须是一个有效的文件描述符,否则close()将失败并设置errno为EBADF。
  2. 每个成功的open()或dup()调用都需要一个对应的close()来关闭文件。如果忘记关闭文件描述符,将导致资源泄露。
  3. close()成功调用后,fd不再引用 opened 文件的任何资源,并可在后续的open()调用中重新使用。
  4. 关闭 stdin、stdout 或 stderr 流的后果是未定义的。这三个流在程序开始时由内核自动打开,并在程序结束时自动关闭。
  5. close()可以在不关闭fd的情况下被重复调用,这种情况下将不进行任何操作并返回成功。
  6. 除非将fd显式声明为int型,否则调用close()时fd的类型需要与open()中的类型相同。
  7. 在进程终止时,所有未关闭的描述符都被自动关闭。但应该养成明确关闭不再需要的文件描述符的好习惯。
  8. close()可能会销毁文件打开时指定的任何记录锁。关闭文件描述符后,任何涉及该描述符的记录锁都变得无效。
  9. 如果文件是以O_APPEND模式打开的,close()将会丢弃追加模式,并将文件描述符的文件位置指针重置为文件开始。

3、实例:多个同类字符设备的驱动

本实例用于一次驱动同种类型的多个字符型设备(主设备号相同,次设备号不同)。以一个内存缓存虚拟一个字符型设备,驱动模块实现对内存缓存虚拟设备的读写操作。

3.1 驱动代码

/*************************************************************************
	> File Name: muti-oper-mem.c
    用一个内存池来虚拟设备,驱动模块完成对内存池的读与写。应用层程序通过系统调用open
    read write操作设备muti0,muti1,muti2。

    本例用于驱动同一类设备的不同子设备。主设备号相同,次设备号不同。
    与单一设备驱动不同之处在以下几点:
    1、mem设备要对应有多个
    2、cdev设备对象一一对应
    3、devno统一用alloc_chrdev_region()生成
    4、每一个cdev设备都要cdev_init和cdev_add
    4、每一个cdev都要cdev_del()
 ************************************************************************/

#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/slab.h>
#include <asm/uaccess.h>


/*1、定义重要的变量及结构体*/

#define  MEM_SIZE 500    //每个内存块的大小500 byte
#define  DEVICE_NUM 3    //定义3个同类设备

dev_t devno;
struct mem_dev_t{
    struct cdev  my_dev;  //cdev设备描述结构体变量
    char  mem[MEM_SIZE]; //内存池,当成虚拟设备

};

/*这个mem_dev_t指针类型,可以指向数组,在这里是指向三个元素的首元素的地址。*/
struct mem_dev_t *mem_dev;  


/*所有驱动函数声明*/
loff_t llseek (struct file *, loff_t, int);
ssize_t read (struct file *, char __user *, size_t, loff_t *);
ssize_t write (struct file *, const char __user *, size_t, loff_t *);
ssize_t aio_read (struct kiocb *, const struct iovec *, unsigned long, loff_t);
ssize_t aio_write (struct kiocb *, const struct iovec *, unsigned long, loff_t);
int iterate (struct file *, struct dir_context *);
unsigned int poll (struct file *, struct poll_table_struct *);
long unlocked_ioctl (struct file *, unsigned int, unsigned long);
long compat_ioctl (struct file *, unsigned int, unsigned long);
int mmap (struct file *, struct vm_area_struct *);
int open (struct inode *, struct file *);
int flush (struct file *, fl_owner_t id);
int release (struct inode *, struct file *);
int fsync (struct file *, loff_t, loff_t, int datasync);
int aio_fsync (struct kiocb *, int datasync);
int fasync (int, struct file *, int);
int lock (struct file *, int, struct file_lock *);
ssize_t sendpage (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long get_unmapped_area(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int check_flags(int);
int flock (struct file *, int, struct file_lock *);
ssize_t splice_write(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t splice_read(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int setlease(struct file *, long, struct file_lock **);
long fallocate(struct file *file, int mode, loff_t offset,  loff_t len);
int show_fdinfo(struct seq_file *m, struct file *f);

//驱动操作函数结构体,成员函数为需要实现的设备操作函数指针
//简单版的模版里,只写了open与release两个操作函数。
struct file_operations fops={
    .open = open,
    .release = release,
    .read = read,
    .write = write,
};

/*3、初始化 cdev结构体的函数,并将cdev结构体与file_operations结构体关联起来*/
/*这样在内核中就有了设备描述的结构体cdev,以及设备操作函数的调用集合file_operations结构体*/
/*为什么要用这个cdev_setup函数,是因为
*  1、如果是要创建多个cdev的话,就需要不同的数据结构体,在my_init可以看到这个结构体是mem_dev,实际是一个数组,
*  看mem_dev = kzalloc(sizeof(struct mem_dev_t) * DEVICE_NUM , GFP_KERNEL);的内存分配指令可以看出,
* 分配的空间是乘以了DEVICE_NUM了。有几个设备就分配了几个空间,连续分布后组成了数组。调用cdev_setup时,传入的实际只是mem_dev[n],即一个元素
* 2、cdev_setup中的操作都 是每个子设备都相同的操作,所以放到这个子函数里来*/
* 
static int cdev_setup(struct mem_dev_t *mem_dev , int num ){
    int unsucc = 0;
    dev_t sdevno = MKDEV(MAJOR(devno),num);    //根据传入的不同的子设备号,重新生成设备号
    cdev_init(&mem_dev->my_dev , &fops);

    mem_dev->my_dev.owner = THIS_MODULE;

    /*4、注册cdev结构体到内核链表中*/
    unsucc = cdev_add(&mem_dev->my_dev , sdevno , 1);
    if (unsucc){
        printk("cdev [%d] add failed \n",num);
        return -1;
    }
    printk("the major of this devno is %d\n",MAJOR(sdevno));
    printk("the minor of this devno is %d\n",MINOR(sdevno));

    return 0;
}

static int __init my_init(void){
    int major ;
    int i =0;
    int unsucc =0;
    mem_dev = kzalloc(sizeof(struct mem_dev_t) * DEVICE_NUM , GFP_KERNEL);
    if (!mem_dev){
        printk(" allocating memory   failed");
        return  -1;
    }

    /*2、创建 devno */
    unsucc = alloc_chrdev_region(&devno , 0 , DEVICE_NUM , "operate_memory");
    if (unsucc){
        printk(" creating devno    failed\n");
        return -1;
    }else{
        major = MAJOR(devno);
        printk("make devno,major = %d  ; \n",major);
    }
    
    /*3、 分别初始化3个cdev结构体,分别关联cdev结构体与file_operations.*/
    /*4、注册cdev结构体到内核链表中*/
    for ( i = 0 ; i < DEVICE_NUM ; i++){
        if (cdev_setup(mem_dev+i , i ) == 0){
            printk("the driver operate_memory  initalization is complete\n");               
        } else {
            printk("the driver operate_memory initalization failed\n");
            return -1;
        }
    }
    return 0;
}


static void  __exit my_exit(void)
{
    int i=0;
    for ( i = 0; i < DEVICE_NUM; i++){
        cdev_del(&((mem_dev+i)->my_dev));
    }
    unregister_chrdev_region(devno , DEVICE_NUM);
    printk("***************the driver operate_memory exit************\n");
}


/*5、驱动函数的实现*/
/*file_operations结构全成员函数.open的具体实现*/

int open(struct inode *pnode , struct file *pf){
    struct mem_dev_t *p = container_of(pnode->i_cdev, struct mem_dev_t , my_dev);
    pf->private_data = p;  //把全局变量指针放入到struct file结构体里
    printk("operate_memory is opened\n");
    return 0;
}


/*file_operations结构全成员函数.release的具体实现*/
int release(struct inode *pnode , struct file *pf){
    printk("operate_memory is closed \n");
    return 0;
}
    

/*file_operations结构全成员函数.read的具体实现*/
ssize_t read (struct file * pf, char __user * buf, size_t size , loff_t * ppos){
    struct mem_dev_t *pdev = pf->private_data;
    int count = 0;
    //判断偏移量的有效性
    if (*ppos >= MEM_SIZE){
        return 0;
    }
    //判断能够读到的字节数量
    if  (size > MEM_SIZE - *ppos){
        count = MEM_SIZE - *ppos;
    }else{
        count = size;
    }

    //copy_from_user返回值大于0失败
    if ( copy_to_user(buf , &pdev->mem[*ppos] , count )){
        return 0;
    }else{
        *ppos += count;
        return count;
    }
        
}

/*file_operations结构全成员函数.write的具体实现*/
ssize_t write (struct file * pf, const char __user *buf, size_t size , loff_t *ppos){
    struct mem_dev_t *pdev = pf->private_data;
    int count = 0;
    //判断偏移量的有效性
    if (*ppos >=MEM_SIZE ){
        return 0;
    }
    //判断能够写入的字节数量
    if (size > MEM_SIZE-*ppos){
        count = MEM_SIZE-*ppos;
    }else{
        count = size;
    }
    //copy_from_user返回值大于0失败
    if ( copy_from_user(&pdev->mem[*ppos] , buf , count)){
        return 0;
    }else{
        *ppos +=count;
        return count;
    }

}

module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("");

以上代码可以做为模版使用。针对一个或多个同类设备来开发类似驱动时,可以使用。

3.2 测试

对地以上这个驱动,不论要使用几个设备,都只需要加载一次该驱动即可。对应不同的设备,需要创建不同的多个设备文件。如本例是3个同类设备。因此就要手动创建3个设备文件(mknod),主设备号一致,次设备号从0开始到2。

使用时,可以用系统命令“> ” 、cat等操作设备文件,也可以写一段应用程序通过系统调用open() 、read()、write()等。具体见下方操作。
一、加载驱动

insmod /drv/muti-oper-memory.ko

二、手动生成设备文件

mknod /dev/muti0 c 251 0
mknod /dev/muti1 c 251 1
mknod /dev/muti2 c 251 2

三、测试

echo “hello /dev/muti0” > /dev/muti0
cat /dev/muti0
在这里插入图片描述

四、应用层测试程序用例

/*************************************************************************
	> File Name: muti_op_mem.c
    对应驱动的应用层测试程序。调用open read write 等 来测试驱动的运行
    分别打开三个驱动进行读写,测试是否正常 

    执行:./muei_op_mem.elf /dev/muti0 /dev/muti1 dev/muti2
************************************************************************/

#include<stdio.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>

#define DEVICE_NUM 3

int  main(int argc , char **argv){

    int fd[DEVICE_NUM] = {0};
    int size = 0;
    char buf[20] = {0};
    char * mesg[3] ={"this is test0\n" , "this is test1\n" , "this is test2\n"};
    int pos = 0;
    int i=0;

    if  (argc < DEVICE_NUM+1){
        printf("argument is  less!\n");
        return 0;
    }
    //向设备写入数据
    for (i = 0; i < DEVICE_NUM; i++){
        printf("open file %s\n",argv[i+1]);
        fd[i] = open(argv[i+1] , O_RDWR|O_APPEND  );
        if (fd[i] < 0){
            perror("open ");
        }

        size = write(fd[i] ,mesg[i] , strlen(mesg[i]));
        printf("write device[%d] size = %d\n",i,size);
        if (size == strlen(mesg[i])){
            printf("write to device[i] is sruccessul\n",i);
        }else{
            printf("write to device[i] is failed\n",i);
        }
    
        sleep(1);
        //printf("sleep is end \n");
        close(fd[i]);
    
        //从设备读出数据
        fd[i] = open(argv[i+1] , O_RDONLY);
        if (fd[i] < 0){
            perror("open");
        }

        size = read(fd[i] , buf , strlen(mesg[i]));
        if (size > 0){
            printf("read data of device[%d] is : %s\n" ,i, buf);
        }else{
            printf("read data of device[%d]  failed\n",i);
        }

        sleep(1);
        //printf("sleep is end \n");
        close(fd[i]);
    }





    return 0;

}

输出结果如下:

在这里插入图片描述

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

骑牛唱剧本

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值