arm-linux 内存映射接口,arm驱动linux设备地址映射到用户空间

原标题:arm驱动linux设备地址映射到用户空间

[《[ ] 到 》涉及内核驱动函数二个,内核结构体二个,分析了内核驱动函数二个;可参考的相关应用程序模板或内核驱动模板二个,可参考的相关应用程序模板或内核驱动四个

一、问题描述:一般情况下,用户空间是不可能也不应该直接访问设备的,但是,设备驱动程序中可实现mmap()函数,这个函数可使用户空间直接访问设备的物理地址。

1、mmap()函数工作原理:mmap()实现了这样的一个映射过程,它将用户的内存空间的一般内存(准确来说是执行mmap进程的映射区域内存)与设备内存关联,当用户访问用户空间的这段地址范围时,实际上会转化为对设备的访问(linux上一切皆文件)。

文件内存映射原理图示 a

2、mmap优点:1、对于设备文件,最大的有点就是用户空间可以直接访问设备内存;2、普通文件被映射到进程地址空间后,进程进程访问文件的速度也变块,不必再调read(),write(),可以用memcpy,strcpy等操作写文件,写完后用msync()同步一下。(感觉还是很抽象,看了后面的实例一就明白了)

3、应用场景:mmap()的这种能力用于显示适配器一类的设备,屏幕帧的像素不再需要从一个用户空间到内核空间的复制过程。

二、应用程序相关函数

1、建立映射:

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

a) 参数含义:

addr: 指定映射的起始地址, 通常设为NULL, 由系统指定。

length: 映射到内存的文件长度。

prot: 映射区的保护方式, 可以是:

PROT_EXEC: 映射区可被执行

PROT_READ: 映射区可被读取

PROT_WRITE: 映射区可被写入

PROT_NONE 映射区不可访问.

flags: 映射区的特性, 可以是:

MAP_SHARED:对此区域所做的修改内容奖写入文件内;允许其他映射该文件的进程共享,意思是:n个mmap.out程序在运行,这n个进程的“虚拟内存区域”的物理空间空间都相同。详看《虚拟内存共享原理图b》

虚拟内存共享原理图b

MAP_PRIVATE:对此区域所做的修改不会更改原来的文件内容,对映射区的写入操作会产生一个映射区的复制(copy-on-write);意思是:n个mmap.out程序在运行,但是虚拟内存区域的物理地址会被内核另外分配。详看《虚拟内存共享原理图c》

虚拟内存共享原理图c

fd: 由open返回的文件描述符, 代表要映射的文件。

offset: 以文件开始处的偏移量, 必须是分页大小的整数倍, 通常为0, 表示从文件头开始映射。

b)返回值:返回成功----函数的返回值为最后文件映射到进程空间的地址(参照文件内存映射原理图示 a),进程可直接操作起始地址为该值的有效地址。返回失败返回MAP_FAI(-1),错误原因存于errno 中。

2、解除映射:

int munmap(void *addr, size_t length);

3、 同步回写函数:

int msync(const void *start, size_t length, int flags);

如果您希望立即将数据写入文件中,可使用msync。

a)参数

start为记忆体开始位置(mmap函数返回的值---地址),length为长度。

flags则有三个:

MS_ASYNC : 请Kernel快将资料写入,发出回写请求后立即返回

MS_SYNC : 在msync结束返回前,将资料写入。

MS_INVALIDATE使用回写的内容更新该文件的其它映射

实例一)mmap普通文件被映射到进程地址空间实例

mmapfile.c

#include

#include

#include

#include

#include

#include

#include

#include

void printfMapChar(char *nameChar, char *mapChar){//打印mapChar的内容

printf("%s = %s\n\n", nameChar,mapChar);

}

void printfMmapAddr(char *nameChar, char *mmapChar){//打印mmapChar的地址

printf("%s'address = %p\n",nameChar, mmapChar);

}

void printfDivLine(char *desc){

printf("********%s*******\n", desc);

}

int main(){

int fd;

char *mapChar;//mapchar存放虚拟内存地址

char *checkChar;//验证是否mapChar是映射地址

printf("mypid is %d\n",getpid());//输出本pid

/*获得映射区域地址,赋值mapChar*/

fd = open("/tmp/test.txt",O_RDWR);

mapChar = mmap(NULL,100,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);//获得映射区域地址MAP_SHARED更改mapchar后改变fd文件内容

/*****************/

Tip:此时mapchar就是虚拟内存区域的物理地址部分的首地址;也就是《文件内存映射原理图示 a》中的fd文件存储映射部分对应的的首地址,当进车访问mapchar这段地址范围时,实际上会转化为对文件fd的访问

/********打印映射区域内容;和mapChar*********/

printfDivLine("打印映射区域内容;和mapChar");

printfMapChar("mapChar", mapChar);

/**************/

/*******通过mapChar将数据写入映射区域*******/

strcpy(mapChar, "writeSrc,writeSrc,writeSrc,writeSrc,writeSrc,writeSrc,");//写入映射区域

printfDivLine("通过mapChar将数据写入映射区域");

printfMapChar("mapChar", mapChar);

/**********checkChar验证*********/

checkChar = mmap(NULL,100,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);//获得映射区域地址

close(fd);//不使用fd时就可以close

printfDivLine("checkChar验证");

printfMmapAddr("mapChar", mapChar);

printfMmapAddr("checkChar", checkChar);

printfMapChar("checkChar", checkChar);

munmap(mapChar, 100);//释放mapchar的映射,此时文件的映射在内存内然存在

munmap(checkChar, 100);

return 0;

}

运行结果:

mypid is 28529

********打印映射区域内容;和mapChar*******

mapChar = this is a just test temp file

********通过mapChar将数据写入映射区域*******

mapChar = writeSrc,writeSrc,writeSrc,writeSrc,writeSrc,writeSrc,

********checkChar验证*******

mapChar'address = 0x7f356eaaa000

checkChar'address = 0x7f356eaa9000

checkChar = writeSrc,writeSrc,writeSrc,writeSrc,writeSrc,writeSrc,

Tip:一个进程的内存映象由下面几部分组成:程序代码、数据、BSS和栈区域,以及内存映射的区域。一个进程的内存区域可以通过查看/proc/pid/maps

三、给驱动设备添加mmap虚拟内存映射

内核函数一)1、 驱动中的mmap(内核空间):

int(*mmap)(struct file *,struct vm_area_struct *);

2、在struct file_operations中与mmap接口函数关联

static struct file_operations module_drv_fops = {

//............

.mmap = memdev_mmap,

//...............

}

结构体一)3、struct vm_area_struct(VMA)结构体如下

struct vm_area_struct {

struct mm_struct * vm_mm; /* The address space we belong to. */

unsigned long vm_start; /* Our start address within vm_mm. */

unsigned long vm_end; /* The first byte after our enddress within vm_mm. */

/* linked list of VM areas per task, sorted by address */

struct vm_area_struct *vm_next;

pgprot_t vm_page_prot; /* Access permons of this VMA. */

unsigned long vm_flags; /* Flags, listed below. */

struct rb_node vm_rb;

struct vm_operations_struct * vm_ops;

/* Information about our backing store: */

unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE units, *not* PAGE_CACHE_SIZE */

struct file * vm_file; /* File we map to (can be NULL). */

void * vm_private_data; /* was vm_pte (shared mem) */

unsigned long vm_truncate_count;/* truncate_count or restart_addr */

//..................

};

4、struct vm_area_struct(VMA)结构体flag参数

VM_IO将该VMA标记为内存映射的IO区域,VM_IO会阻止系统将该区域包含在进程的存放转存(core dump )中

VM_RESERVED标志内存区域不能被换出

内核函数二) 5、内核mmap中创建页表函数:remap_pfn_range();

作用用“addr ~ addr + size之间的虚拟地址”构造页表,参考《虚拟内存共享原理图b》和《虚拟内存共享原理图c》

int remap_pfn_range(struct vm_area_struct *vma, unsigned long addr,

unsigned long pfn, unsigned long size, pgprot_t prot)

a)参数

1) vma: 虚拟内存区域指针(默认使用vma)

2)addr: 虚拟地址的起始值(默认使用vma->vm_start)

3)pfn:总的来说(pfn = virt_to_phys(void *mem))>>PAGE_SHIFT(常用);或使用默认方式vma->vm_pgoff)

推理:pfn是虚拟地址应该映射到的物理地址所在的物理页帧号就是物理地址右移PAGE_SHIFT位,若PAGE_SIZE为4k则PAGE_SHIFT为12(2的12次方为4k),因此PAGE_SIZE为1<>PAGE_SHIFT"得到(虚拟地址 = 物理地址>>PAGE_SHIFT)。如何得到物理地址:将驱动设备中某个内存变量用函数virt_to_phys(void *mem)转换成物理地址;(物理地址 = virt_to_phys(void *mem));

4)size: 要映射的区域的大小。(默认使用vma->vm_end - vma->vm_start)

5)prot: VMA的保护属性。(默认使用vma->vm_page_prot)

模板一)6、mmap驱动模板

static int memdev_mmap(struct file*file, struct vm_area_struct *vma){

struct VialDisk *devp = file->private_data; /*鑾峰緱璁惧缁撴瀯浣撴寚閽?/

vma->vm_flags |= VM_IO;

vma->vm_flags |= VM_RESERVED;

if (remap_pfn_range(vma,vma->vm_start,virt_to_phys(devp->mem)>>PAGE_SHIFT, vma->vm_end - vma->vm_start, vma->vm_page_prot))

return -EAGAIN;

return 0;

}

file_oprations中添加或修改.mmap

static struct file_operations module_drv_fops = {

//............

.mmap = memdev_mmap,

//...............

}

实例二)7、完整实例

a)驱动部分

//“module_drv”,"module_","module"

#include //模块所需的大量符号和函数定义

#include

#include //文件系统相关的函数和头文件

#include //指定初始化和清除函数

#include

#include //cdev结构的头文件包含

#include

#include

//#include //包含驱动程序使用的大部分内核API的定义,包括睡眠函数以及各种变量声明

#include //在内核和用户空间中移动数据的函数

#include

#include

#include

#include

#define VIRTUALDISK_SIZE 0x1000//4k

#define MEM_CLEAR 0x1

#define VIRTUALDISK_MAJOR 250

int VirtualDisk_major = VIRTUALDISK_MAJOR;

struct VirtualDisk{

struct cdev cdev;//详细看cdev机制

unsigned char mem[VIRTUALDISK_SIZE ];

long count; /*记录设备目前被多少设备打开*/

};

static struct class *module_class;

static struct class_device *module_class_dev;

struct VirtualDisk *VirtualDiskp;

static int module_drv_open(struct inode *inode, struct file *file)

{

printk("module_dev read\n");

file->private_data = VirtualDiskp;

VirtualDiskp->count++; /*增加设备打开次数*/

return 0;

}

static int module_drv_release(struct inode *inode, struct file *file)

{

printk("module_dev release\n");

VirtualDiskp->count--; /*减少设备打开次数*/

return 0;

}

/*seek文件定位函数:seek()函数对文件定位的起始地址可以是文件开头(SEEK_SET,0)、当前位置(SEEK_CUR,1)、文件尾(SEEK_END,2)*/

static loff_t module_drv_llseek(struct file *file, loff_t offset, int origin){

loff_t ret = 0;/*返回的位置偏移*/

switch (origin)

{

case SEEK_SET: /*相对文件开始位置偏移*/

if (offset < 0)/*offset不合法*/

{

ret = - EINVAL; /*无效的指针*/

break;

}

if ((unsigned int)offset > VIRTUALDISK_SIZE)/*偏移大于设备内存*/

{

ret = - EINVAL; /*无效的指针*/

break;

}

file->f_pos = (unsigned int)offset; /*更新文件指针位置*/

ret = file->f_pos;/*返回的位置偏移*/

break;

case SEEK_CUR: /*相对文件当前位置偏移*/

if ((file->f_pos + offset) > VIRTUALDISK_SIZE)/*偏移大于设备内存*/

{

ret = - EINVAL;/*无效的指针*/

break;

}

if ((file->f_pos + offset) < 0)/*指针不合法*/

{

ret = - EINVAL;/*无效的指针*/

break;

}

file->f_pos += offset;/*更新文件指针位置*/

ret = file->f_pos;/*返回的位置偏移*/

break;

default:

ret = - EINVAL;/*无效的指针*/

break;

}

return ret;

}

/*设备控制函数:ioctl()函数接受的MEM_CLEAR命令,这个命令将全局内存的有效数据长度清零,对于设备不支持的命令,ioctl()函数应该返回-EINVAL*/

static int module_drv_ioctl(struct inode *inode, struct file *file, unsigned int cmd, unsigned long arg){

struct VirtualDisk *devp = file->private_data;/*获得设备结构体指针*/

switch (cmd)

{

case MEM_CLEAR:/*设备内存清零*/

memset(devp->mem, 0, VIRTUALDISK_SIZE);

printk(KERN_INFO "VirtualDisk is set to zero\n");

break;

default:

return - EINVAL;

}

return 0;

}

/*读函数:读写函数主要是让设备结构体的mem[]数组与用户空间交互数据,并随着访问字节数变更返回用户的文件读写偏移位置*/

static ssize_t module_drv_read(struct file *file, const char __user *buf, size_t count, loff_t * ppos)

{

unsigned long p = *ppos; /*记录文件指针偏移位置*/

unsigned int countt = count;/*记录需要读取的字节数*/

int ret = 0; /*返回值*/

struct VirtualDisk *devp = file->private_data; /*获得设备结构体指针*/

printk("module_dev read\n");

/*分析和获取有效的读长度*/

if (p >= VIRTUALDISK_SIZE ) /*要读取的偏移大于设备的内存空间*/

return countt ? - ENXIO: 0;/*读取地址错误*/

if (countt > VIRTUALDISK_SIZE - p)/*要读取的字节大于设备的内存空间*/

countt = VIRTUALDISK_SIZE - p;/*将要读取的字节数设为剩余的字节数*/

/*内核空间->用户空间交换数据*/

if (copy_to_user(buf, (void*)(devp->mem + p), countt))

{

ret = - EFAULT;

}

else

{

*ppos += countt;

ret = countt;

printk("read %d bytes(s) is %ld\n", countt, p);

}

printk("bytes(s) is %s\n", buf);

return ret;

}

/*

file 是文件指针,count 是请求的传输数据长度,buff 参数是指向用户空间的缓冲区,这个缓冲区或者保存要写入的数据,或者是一个存放新读入数据的空缓冲区,该地址在内核空间不能直接读写,ppos 是一个指针指向一个"long offset type"对象, 它指出用户正在存取的文件位置. 返回值是一个"signed size type。写的位置相对于文件开头的偏移。

*/

static ssize_t module_drv_write(struct file *file, const char __user *buf, size_t count, loff_t * ppos)

{

unsigned long p = *ppos; /*记录文件指针偏移位置*/

int ret = 0; /*返回值*/

unsigned int countt = count;/*记录需要写入的字节数*/

struct VirtualDisk *devp = file->private_data; /*获得设备结构体指针*/

printk("module_dev write\n");

/*分析和获取有效的写长度*/

if (p >= VIRTUALDISK_SIZE )/*要写入的偏移大于设备的内存空间*/

return countt ? - ENXIO: 0;/*写入地址错误*/

if (countt > VIRTUALDISK_SIZE - p)/*要写入的字节大于设备的内存空间*/

countt = VIRTUALDISK_SIZE - p;/*将要写入的字节数设为剩余的字节数*/

/*用户空间->内核空间*/

if (copy_from_user(devp->mem + p, buf, countt))

ret = - EFAULT;

else

{

*ppos += countt;/*增加偏移位置*/

ret = countt;/*返回实际的写入字节数*/

printk("written %d bytes(s) from%ld\n", countt, p);

}

return ret;

return 0;

}

static int memdev_mmap(struct file*file, struct vm_area_struct *vma){

struct VirtualDisk *devp = file->private_data; /*获得设备结构体指针*/

vma->vm_flags |= VM_IO;

vma->vm_flags |= VM_RESERVED;

if (remap_pfn_range(vma,vma->vm_start,virt_to_phys(devp->mem)>>PAGE_SHIFT, vma->vm_end - vma->vm_start, vma->vm_page_prot))

return -EAGAIN;

return 0;

}

static struct file_operations module_drv_fops = {

.owner = THIS_MODULE, /* 这是一个宏,推向编译模块时自动创建的__this_module变量 */

.open = module_drv_open,

.read = module_drv_read,

.write = module_drv_write,

.release = module_drv_release,

.llseek = module_drv_llseek,

.ioctl = module_drv_ioctl,

.mmap = memdev_mmap,

};

/*将 cdev 结构嵌入一个你自己的设备特定的结构,你应当初始化你已经分配的结构使用以上函数,有一个其他的 struct cdev 成员你需要初始化. 象 file_operations 结构,struct cdev 有一个拥有者成员,应当设置为 THIS_MODULE,一旦 cdev 结构建立, 最后的步骤是把它告诉内核, 调用:

cdev_add(&dev->cdev, devno, 1);*/

static void VirtualDisk_setup_cdev(struct VirtualDisk *dev, int minorIndex){

int err;

int devno = MKDEV(VirtualDisk_major, minorIndex);

cdev_init(&dev->cdev, &module_drv_fops);

dev->cdev.owner = THIS_MODULE;

err = cdev_add(&dev->cdev, devno, 1);

if(err){

printk("error %d cdev file added\n", err);

}

}

static int module_drv_init(void)

{

int result;

dev_t devno = MKDEV(VirtualDisk_major, 0);

if(VirtualDisk_major){

result = _region(devno, 1, "module");

}else{

result = alloc_chrdev_region(&devno, 0, 1, "module");

VirtualDisk_major = MAJOR(devno);

}

if(result < 0 ){

return result;

}

VirtualDiskp = kmalloc(sizeof(struct VirtualDisk), GFP_KERNEL);

if(!VirtualDiskp){

result = -ENOMEM;

goto fail_malloc;

}

memset(VirtualDiskp, 0, sizeof(struct VirtualDisk));

VirtualDisk_setup_cdev(VirtualDiskp, 0);

module_class = class_create(THIS_MODULE, "module_drv");

if (IS_ERR(module_class))

return PTR_ERR(module_class);

module_class_dev = class_device_create(module_class, NULL, MKDEV(VirtualDisk_major, 0), NULL, "module"); /* /dev/xyz */

if (IS_ERR(module_class_dev))

return PTR_ERR(module_class_dev);

return 0;

fail_malloc:

unregister_chrdev_region(devno, 1);

return result;

}

static void module_drv_exit(void)

{

cdev_del(&VirtualDiskp->cdev);

kfree(VirtualDiskp);

unregister_chrdev_region(MKDEV(VirtualDisk_major, 0), 1);

class_device_unregister(module_class_dev);

class_destroy(module_class);

}

module_init(module_drv_init);

module_exit(module_drv_exit);

MODULE_LICENSE("GPL");

实例三)b)与驱动对应的应用程序部分

#include

#include

#include

#include

#include

#include

int main(){

int fd;

char *start;

//char buf[100];

char *buf;

char *end;

char key_vals[20];

/*打开文件*/

printf("mypid is %d",getpid());

fd = open("/dev/module",O_RDWR);

buf = (char *)malloc(100);

memset(buf, 0, 100);

start=mmap(NULL,10,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);

end = mmap(NULL, 20,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);//addr为NULL,由系统分配

/* 读出数据 */

strcpy(buf,start);

sleep (1);

printf("buf 1 = %s\n",buf);

/* 写入数据 */

strcpy(start,"Buf Is Not Null!rgrgrfgfgfdg");

memset(buf, 0, 100);

strcpy(buf,start);

sleep (1);

printf("buf 2 = %s\n",buf);

/* 写入数据 */

strcpy(end,"is it reality! is not sure,are you ok, make sure ,you");

memset(buf, 0, 100);

strcpy(buf,start);

sleep (1);

printf("buf 3 = %s\n",buf);

printf("buf 3 = %s\n",end);

read(fd, key_vals, sizeof(key_vals));

printf("key_vals 3 = %s\n",key_vals);

// while(1);

munmap(start,10); /*解除映射*/

munmap(end,20); /*解除映射*/

free(buf);

close(fd);

return 0;

}

第三部分:struct stat 作用

1、stat,lstat,fstat1 函数都是获取文件(普通文件,目录,管道,socket,字符,块()的属性。函数原型#include

2、向stat,fstat1、lstat传入文件名字path、fd、path,获取文件对应属性buf。

int stat(const char *path, struct stat *buf); //文件路径或文件名

int fstat(int fd, struct stat *buf);//文件描述符

int lstat(const char *path, struct stat *buf);//连接文件

结构体二)struct stat结构

struct stat {

mode_t st_mode; //文件对应的模式,文件,目录等

ino_t st_ino; //inode节点号

dev_t st_dev; //设备号码

dev_t st_rdev; //特殊设备号码

nlink_t st_nlink; //文件的连接数

uid_t st_uid; //文件所有者

gid_t st_gid; //文件所有者对应的组

off_t st_size; //普通文件,对应的文件字节数(常用)

time_t st_atime; //文件最后被访问的时间

time_t st_mtime; //文件内容最后被修改的时间

time_t st_ctime; //文件状态改变时间

blksize_t st_blksize; //文件内容对应的块大小

blkcnt_t st_blocks; //文件内容对应的块数量

};

四、与mmap应用程序中“普通文件虚拟内存映射模板和实例

模板二)1、 mmap()应用程序模板

int fd;

/*获得映射区域地址,赋值mapChar*/

fd = open("/tmp/test.txt",O_RDWR);

struct stat fileStat;

/* 获取文件的属性 */

if ((fstat(fd, &fileStat)) == -1) {

perror("fstat");

}

unsigned int fileBufferSize;

fileBufferSize = fileStat.st_size;/*mmap回写时,字节最大大小

为fileStat.st_size,所以定义字节大fileStat.st_size*/

mmap(NULL,fileBufferSize,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);//获得映射区域地址

munmap(checkChar, fileBufferSize);

实例四)2、完整实例

#include

#include

#include

#include

#include

#include

#include

#include

#include

void printfMapChar(char *nameChar, char *mapChar){

printf("%s = %s\n\n", nameChar,mapChar);

}

void printfDivLine(char *desc){

printf("********%s*******\n", desc);

}

int main(){

int fd;

char *mapChar;

char *checkChar;//验证是否mapChar是映射地址

struct stat fileStat;

printf("mypid is %d\n",getpid());//输出本pid

/*获得映射区域地址,赋值mapChar*/

fd = open("/tmp/test.txt",O_RDWR);

/* 获取文件的属性 */

if ((fstat(fd, &fileStat)) == -1) {

perror("fstat");

}

unsigned int fileBufferSize;

fileBufferSize = fileStat.st_size;/*mmap回写时,字节最大大小

为fileStat.st_size,所以定义字节大fileStat.st_size*/

Tip:mmap回写时,回写字节最大大小为fileStat.st_size,所以定义字节大fileStat.st_size。(这个我没有根据,只是实验结果是这样)

mapChar = mmap(NULL,fileBufferSize,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);//获得映射区域地址MAP_SHARED更改mapchar后改变fd文件内容

/*****************/

/********打印映射区域内容;和mapChar*********/

printfDivLine("打印映射区域内容;和mapChar");

printfMapChar("mapChar", mapChar);

/**************/

/*******通过mapChar将数据写入映射区域*******/

strcpy(mapChar, "writeSrc,writeSrc,writeSrc,writeSrc,writeSrc,writeSrc,");//写入映射区域

printfDivLine("通过mapChar将数据写入映射区域");

printfMapChar("mapChar", mapChar);

/**********checkChar验证*********/

checkChar = mmap(NULL,fileBufferSize,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);//获得映射区域地址

close(fd);//不使用fd时就可以close

printfDivLine("checkChar验证");

printfMapChar("checkChar", checkChar);

munmap(mapChar, fileBufferSize);//释放mapchar的映射,此时文件的映射在内存内然存在

munmap(checkChar, fileBufferSize);

return 0;

}

责任编辑:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值