linux的内存管理方式

为什么linux需要使用虚拟地址?--1.为了使用户操作的内存地址够大!2.mmu对地址的访问性和属性进行安全检查,虚拟地址必须映射到实际的物理地址才能进行访问。

mmu:内存管理单元,管理内存的并把虚拟地址转换为物理地址的硬件,以页为单位处理,并进行内存访问权限保护等。进行虚拟地址和物理地址的检查(常见的段错误) linux内存最小管理单位为页,为4kb

***********************************************************************

1.linux内核内存相关内容: 1.内存空间和io空间 x86架构:有两类总线

一类总线的位宽为16位(64k),如果将外设接到这个总线上,那么cpu访问这个外设通过in,out指令来完成访问;这个地址空间称之为io空间;另一类总线的位宽32位,硬件地址空间范围是4g,如果将外设接到这个总线上,比如内存,GPIO寄存器,nandflash控制器等,那么cpu访问这些外设通过地址或者指针的形式来访问这个地址空间称之为内存空间.

arm架构:只有内存空间;无io内存空间一说,外设的IO内存空间都当做内存空间一样来进行访问;arm只有内存空间的原因: 

arm与powerpc两种处理器跟x86不同,arm与powerpc一样,它们的外设I/O端口是统一编址的,即与物理内存等外设统一编址在4GB的地址空间中(32为处理器)。而x86是将内存单独编址在一个地址空间,外设I/O端口在另外的地址空间,要访问IO地址空间需要用专门的指令操作。

2.物理地址和虚拟地址 明确:cpu最终访问的地址都是物理地址;

明确:不管是在内核空间还是用户空间,程序访问的地址都是虚拟地址;内核访问的是内核1g虚拟地址,用户进程访问的是进程3g虚拟地址。

虚拟地址的优点:

1.如果物理内存比如只有1G,采用虚拟地址这个策略,能够使用户看到比实际的物理内存大很多的内存空间;2.由于cpu最终访问的地址是物理地址,所以在进行虚拟地址到物理地址转换的时候,可以对进行有效性和权限的检查,以保证系统的安全。3.4g虚拟地址空间的划分:

用户空间:0X00000000-0XBFFFFFFF

内存空间:  0XC0000000--0xFFFFFFFF

4.用户进程都有自己独立的3G空间,互不干涉;但是内核的空间1G,所有的进程共享;

5.用户空间和内核空间要进行数据交互,必须通过系统调用。 

3.MMU:mmu是cpu自带的硬件逻辑单元,一旦有这个硬件单元,那么cpu就可以采用虚拟地址策略.

mmu的主要职责:

1.将虚拟地址转换成对应的物理地址,根据页表。

2.检查地址的有效性和权限,决定是访问操作这个地址还是给cpu发送一个异常信号!


4.TLB:tlb是mmu自带的硬件逻辑单元,类似“Cache”,它用于页表的缓存,每当mmu进行地址转换的时候,首先到TLB中找转换关系,如果找到,进行转换,如果没有找到,就从主存中取出页表信息进行地址转换,更新TLB,为下一次的转换做准备! 

5.uclinux:运行在不带mmu,不支持mmu的cpu上,处理的地址都是物理地址,ARM7 ,dsp处理器 fpga+arm核 s3c4510 s3c44b0等 

6.物理内存的页:linux内核管理物理内存,单位不是按字节,按页进行管理,一页默认4k,内核描述每一个物理页都是使用struct page结构体,如果这个物理地址和一个虚拟地址做好了映射,这个结构体中的virtual字段保存映射的虚拟地址;内核初始化时,给每一个物理页创建一个struct page结构体来描述每一个物理页的信息,所占用的内存为;sizeof(struct page)*1G/4k 

7.用户空间,内核空间和物理内存的映射关系:用户空间3G和物理内存的映射是一个动态的过程,也就是说用户在用户空间需要访问某个物理内存,就动态创建物理内存和用户虚拟地址的映射(建立页表),如果不在使用,解除映射关系(销毁页表),这个访问方式效率不大!用户空间能够访问的物理内存最大也只有3G; 内核空间1G和物理内存的映射是在内核初始化时就已经建立一一映射关系,一旦建立好,

后续访问无需动态建立页表,加快地址的访问速度。物理和虚拟地址的一一映射;

物理地址                虚拟地址

0x0 0xC0000000

0X1 0XC0000001

0X2 0XC0000002

.......... .......

 

8.问题:内核虚拟地址为1G,如果物理内存大于1G,如果让内核访问其它的物理内存呢?

答:linux内核将1g的虚拟地址划分为若干个区域来实现所有的物理内存。 

x86:将1g的内核虚拟内存地址分别划分为4个区域:直接内存映射区 动态内存映射区 永久内存映射区 固定内存映射区

直接内存映射区 映射关系:在内核初始化(不是动态映射),如果物理内存大于896mb(1g),内核将内核的1g虚拟地址前896mb虚拟内存和物理进行一一映射;1g的虚拟内存还剩余128m,

起始地址:0XC000000大小如果物理内存大于1g,那么直接内存区的大小为896mb,如果物理内存小于896mb,那么物理内存的大小就是直接内存映射区的大小 别名:低端内存

动态内存映射区 映射关系:如果需要某一个物理地址(物理内存地址 寄存器地址),,只需动态的建立物理地址和动态内存映射区的映射关系(页表),即可,每当程序访问这个内核的动态内存映射的虚拟地址其实最终也是访问对应的物理地址(mmu);如果不在使用对应的物理地址,一定要解除映射关系。

起始地址:随着物理内存的大小变化而变化,如果物理内存小于896mb,比如512m,起始地址=0xc000000+物理内存的大小,如果物理内存大于896mb,起始地址0xC0000000+896mb,默认的大小为120m。 

永久内存映射区:映射关系:它也是实现物理地址和内核虚拟地址的动态映射,只是如果在某些时刻,访问这个物理地址的频率很大,如果频繁的建立页表,那么访问效率不高,于是可以将这个物理地址映射到永久内存映射区,一经建立映射关系,无需再销毁对应的映射关系,加快地址的访问速度。当然可以人为的销毁这个映射关系。使用kmap函数进行映射,这个映射有可能会休眠,所以不能再中断上下文使用。大小:4M 

固定内存映射区:和永久内存映射区的目的是一样的,区别仅仅是固定内存映射区的虚拟地址映射的时候,可以使用在中断的上下文中,禁止内核抢占。

用户虚拟地址划分:栈 mmap映射区 堆 bbs 数据段 代码段 

注意:直接内存映射区又叫低端内存:

动态内存映射区+永久内存映射区+固定内存映射区=高端内存;

 

arm体系:1g的内核虚拟内存区域的划分:5个区域。异常向量表(vetor)、固定内存映射区、DMA内存映射区、动态内存映射区、直接内存映射区。注意:arm架构本身的异常向量表的入口地址0xffff0000--0xffff1000和0地址处。3g的用户空间与物理内存访问需要在内核的动态映射区访问。

S5PV210处理器1G内核虚拟地址的划分:Linux内核启动打印的内核信息:

Virtual kernel memory layout:

    vector  : 0xffff0000 - 0xffff1000        ( 4 kB)   //异常向量表入口地址

    fixmap  : 0xfff00000 - 0xfffe0000       ( 896 kB) //固定内存映射区

    DMA     : 0xff000000 - 0xffe00000     ( 14 MB) //dma内存映射

    vmalloc : 0xf4800000 - 0xfc000000      (120 MB) / /动态内存映射区

    lowmem  : 0xc0000000 - 0xf4000000    ( 832 MB) //低端内存(直接内存映射区)

    modules : 0xbf000000 - 0xc0000000     ( 16 MB)

      .init : 0xc0008000 - 0xc003a000       ( 200 kB)

      .text : 0xc003a000 - 0xc09ad000      (9676 kB)

      .data : 0xc09ae000 - 0xc0a15d20      ( 416 kB)

内核启动的时候将会把内核的1g内存映射进行初始化,页表初始化放在内存中。 

虚拟地址 = (物理地址--物理地址的起始地址) +  C0000000  ; 

9.linux内核分配内存的方法:

1.kmalloc/freevoid *kmalloc(size_t size,gfp_t flags);

函数功能:从低端内存(直接内存映射区)分配,物理上连续,虚拟上也连续(因为在这个空间的虚拟内存和物理内存一一对应),一次性最小分配32字节,新版本内核(2.6.33以后)最大只能分配4MB,之前内核最大只能128kb,不同版本不一样。

参数: size:大小,最小32字节,最大4m/128k flags:分配内存的行为

GFP_KERNEL:告诉内核请努力的帮我分配好内存,如果内存不足,会导致休眠,所以这个宏不能用于中断上下文中。分配失败将会进入休眠。

GFP_ATOMIC:如果再分配内存时,等于告诉内核如果内存不足,不做休眠,而是理解返回一个-ENOMEM,可以用于在中断上下文中; .....还有宏 返回值:就是分配内核虚拟内存的起始地址 

void kfree(void *addr); //释放内存 

2.__get_free_pages(); //free_pages;

unsigned long __get_free_pages(gtp_t gfp_mask,unsigned int order);

函数的功能:这个函数由kmalloc实现,它也是从低端内存分配,物理,虚拟上都是连续的

,最小为一页,最大为4M不分内核版本。 

参数:gfp_mask:分配标志,用于控制__get_free_pages分配内存的行为GFP_KERNEL,GFP_ATOMIC

order:请求或释放的页数的2的幂;如果order=0,分配1页,order=1,分配2页order=2,分配4页

返回值:分配内核虚拟内存的起始地址

void free_pages(unsigned long addr,unsigned int order);//释放内存

3.vmalloc/free:void *vmalloc(unsigned long size);

函数功能:分配内存从动态内存映射区分配,虚拟上连续的,但是物理地址不一定连续! 

释放内存 vfree(void *addr);

注意:vmalloc不但需要获取内存,而且需要建立页表进行mmu的转换。 

4.内存分配的其它方法: 4.1在驱动中定义一个全局数组,例如

static char buf[5*1024*1024]; //bss段,不影响文件的大小

static char buf[5*1024*1024]={0xaa,0x55,};  //数据段,如果没有使用,编译时候回优化

最终会影响ko文件的大小,影响ko文件的加载速度! arm-linux-objdump -j .data -s hello.ko

4.2通过设置内核的启动参数调整将动态内存映射区的大小:

setenv bootargs rooy=/dev/nfs ...vmalloc=256M //将动态内存映射区的大小由120m调整为256m

4.3通过设置内核的启动参数,添加mem=8M来将物理内存高地址的8M,预留出来,然后驱动通过ioremap函数将这个物理内存映射到直接内存映射区中在使用。

*********************************************************************

10.三个内存分配的方法 问题:如何在设备驱动中,访问设备的物理地址呢?例如设备驱动访问GPIO对应的寄存器来实现对灯的操作! 

答:千万明确一点:不管是在内核空间还是在用户空间,软件一律不能去直接访问物理地址,只能访问用户的虚拟地址或者内核虚拟地址,如果驱动要访问物理地址,思路就是将物理地址映射到内核的虚拟地址上即可,一旦映射成功,以后访问这个内核虚拟地址就是在访问物理地址! 这里使用:ioremap函数来实现; ioremap函数

函数原型:void *ioremap(unsigned long phy_addr,int size);

函数功能:给定一个物理地址,将这个物理地址映射到内核的虚拟地址上,映射到内核的动态内存映射区的虚拟地址上。这个过程是动态映射的过程(动态建立页表)

参数说明:phy_addr:要访问的物理地址size:映射内存区域的大小

返回值:就是映射的内核虚拟地址,一旦有这个映射好的内核虚拟地址,以后用户访问这个内核虚拟地址就是在访问对应的物理地址;例如:物理起始地址0x10000000,地址空间为12字节;

void *addr = ioremap(0x10000000,12);注意:映射完毕以后,物理上连续,虚拟上也连续!

映射的关系:

物理地址 虚拟地址

0x10000000 addr

0x10000004 addr+4

0x10000008 addr+8.... ....

注意:一个物理地址可以有多个虚拟地址;一旦不在使用物理地址,一定要解除地址映射;

iounmap(void *addr);//addr就是映射的内核虚拟地址

案例:寄存器编辑软件

写过程:用户应用需要将操作的地址和数据发送给内核

./regeditor_test w 0xe020060 0x11000./regeditor_test w 0xe200064 0x18

读过程用户应用需要将操作的地址发送给内核

./regeditor_test r 0xe020060 ./regeditor_test r 0xe200064

注意:在读取寄存器的时候,只需要关心要观察的bit位即可!

11.Mmap用户虚拟内存区域

********************************************************************************************

案例:分析ioremap实现led开关驱动分析:

1.明确:不管是在用户空间还是在内核空间,软件一律不能去直接访问设备的物理地址;

2.在内核驱动中如果要访问设备的物理地址,需要利用ioremap将设备的物理地址映射到内核虚拟地址上)—动态内存映射区),以后驱动程序访问这个内核虚拟地址就是在间接得访问设备的物理地址(MMU,TLB,TTW)

3.如果用户要访问硬件设备,不能直接访问,也不能在用户空间访问,只能通过系统调用(open,close,read,write,ioctl)来访问映射好的内核虚拟地址,通过这种间接得访问来访问硬件设备,但是如果设计数据的的拷贝,还需借助4个内存拷贝函数来实现!strace ./a.out 

结论:通过以上的分析,发现应用程序通过read,write,ioctl来访问硬件设备,他们都要经过两次的数据拷贝,一次是用户空间和内核空间的数据拷贝,另外一次是内核空间和硬件之间的数据拷贝,如果拷贝的数据量比较小,那么read,write,ioctl的两次数据拷贝的过程对系统的影响可以忽略不计,如果设备的数据类型量非常大,例如显卡(独立),lcd屏幕(显存共享主存),摄像头,声卡这类设备设计的数据量比较庞大,如果还是用read,write,ioctl进行访问设备数据,无形对系统的性能影响非常大。

2.用户访问设备,最终其实设计的用户和硬件,而read,write,ioctl本身会牵扯到内核,所以这些函数设计2次数据拷贝,用户要直接去访问硬件设备,只需将硬件设备的物理地址信息映射到用户的虚拟地址即可,一旦完毕,不会再牵扯到内核空间,以后用户直接访问用户的虚拟地址就是访问设备硬件,由2次数据拷贝的转化为一次的数据拷贝! 

3.如何实现将硬件设备的物理地址映射到用户空间的虚拟地址内存上呢?

用户空间的3g虚拟内存区域的划分:

高地址开始:

栈区 mmap内存映射区 堆区bss段区data段区 Text段区

mmap内存映射区作用:应用程序使用的动态库映射到这个区域;应用程序调用mmap,将设备物理地址和这个区域的虚拟内存进行映射;

结论:linux系统通过mmap来实现将物理地址映射到用户3g的mmap内存区上的虚拟内存上! 

mmap系统调用的过程:

void *addr;

addr =mmap(0, 0x1000,PORT_READ|PORT_WRITE, MAP_SHARED, fd, 0);

1.应用程序调用mmap,首先调用C库的mmap .2.C库的mmap保存mmap的系统调用号保存到R7中,然后调用svc触发软中断异常(陷入内核空间)3.内核启动时,已经初始化好了异常向量表,触发软中断,跳转到软中断的异常向量表的入口地址vectot_swi。 4.根据R7保存的系统调用号,以他为索引,在内核的系统调用表找到对应的sys_mmapa,然后调用内核实现的sys_mmap。 5.sys_mmap在内核中会做两件事情:

(1)首先在当前进程中的MMAP内存映射区中找到一块空闲的虚拟内存区域 (2)一旦找到以后,利用struct vm_area_struct 结构创建一个对象来描述这块空闲的虚拟内存区域6.sys_mmap最终调用底层驱动的mmap,然后将描述空闲虚拟内存区域的对象指针传递给底层驱动的mmap函数使用 7.底层驱动的mmap根据传递过来的虚拟内存区域的信息获取用户要映射的虚拟地址,在根据某些函数建立用户虚拟地址和物理地址的映射关系 8.一旦建立映射,mmap函数返回,返回值保存着这块空闲内存区域的起始地址,以后用户在用户空间就可以使用了。

Struct vm_area_struct {

     Unsigned long vm_start; //空闲的虚拟内存的起始地址

     Unsigned long vm_end; //结束地址

     Pgprot_t vm_page_prot; //访问权限

     Unsigned long vm_pgoff; // 偏移量

};

9.驱动mma利用一下函数建立映射(用户虚拟地址和物理地址)

Inr remap_pfn_range(structvm_area_struct *vma, unsigned long addr, unsigned long pfn ,unsignedsize,pgprot_t prot): vma:用户虚拟内存区域指针addr:用户虚拟内存起始地址->vma->vm_start pfn:要映射物理地址所在的页帧号,可以通过物理地址>>12得到 size:待映射的内存区域的大小 prot:vma的保护属性vma->vm_page_prot 。功能:建立已知的用户虚拟内存和已知的物理地址之间的映射关系;

注意;利用这个函数进行地址映射的时候,不管是物理地址还是用户虚拟地址都要求是页的整数倍。

1页= 4k = 0x1000

0xe0200080这个GPIO寄存器地址不是页的整数倍!通过查看芯片手册可以GPIO使用地址范围:

0XE0200000—0XE02FFFFF,映射时指定的物理地址应该是:0XE0200000(页的整数倍)

访问0xe0200080:用户虚拟地址 + 0x80 访问0xe0200084:用户虚拟地址 + 0x84。

注意:一个物理地址同时可以映射内核的虚拟地址上,还可以映射到用户的虚拟地址上。

(可以使用ioremap先将物理地址映射到内核的虚拟地址上,然后使用用户空间调用mmap驱动的mmap,驱动的mmap中通过remap_pfn_range将其映射到用户空间,此时实现一个物理地址同时映射到内核虚拟地址和用户虚拟地址的情况)

Linu驱动之软硬分离:

1.案例:分析ioremap案例中的驱动,然后如果把led对应的gpio换成别的gpio,需要做哪些修改:

cw210开发板 tarena开发板

GPC1_3 GPC0_3 GPC1_4 GPC0_4

0xe0200080 0xe0200084

0xe0200060 0xe0200064 

总结:通过分析驱动代码,发现驱动包含两部分内容:一部分纯软件信息,例如设备号,字符设备对象,设备类,逻辑运算。另一部分纯硬件信息,例如GPIO的编号,GPIO寄存器的地址,如果这个驱动程序要实现从错cw21平台转移到别的平台,发现原先驱动几乎都要从头到尾看一遍,检查一遍,但凡设计硬件相关的内容,都需要修改,但是纯软件的内容无需代码,这个代码的可移植非常差(移植性非常差:硬件一变,要修改的内容太多了;)pr_deug("pi= %f\n",3.14); 改正:

#define PI (3.1415)pr_debug("pi=%f\n",PI); 

2.问:如何解决驱动代码的可移植性非常差的问题?答:明确:驱动=硬件+软件

linux内核提出了“分离思想”用于解决驱动的移植性问题!分离思想:本质目的就是将驱动涉及的纯硬件和纯软件进行分离!如果一旦硬件进行,只需要修改纯硬件部分的内容,纯软件的的内容无需修改。 

3.linux内核分离思想的实现过程:1.首先内核已经帮你定义好了一个虚拟总线(软件实现)platform_bus_type(平台总线),并且在这个总线上面维护这两个链表dev和drv链表;内核虚拟总线的对应数据类型:struct bus_type

2.dev链表上每一个节点存放的硬件相关的信息,并且对应的硬件节点的数据结构struct platform_device。每当用这个结构体分配一个对象,然后根据实际的硬件初始化这个对象,然后注册添加到dev链表上,最后内核会帮你去遍历drv链表,取出drv链表上每一个节点,调用总线提供的match函数,比较软件节点和硬件节点的name是否相等(strcmp),如果相等内核会调用软件节点的probe函数,然后将硬件节点的首地址传递probe函数,让probe函数获取硬件操作硬件,如果没有匹配成功(没有找到匹配的软件节点),那就等待软件节点的到来! 

3.实际驱动开发只需关注struct platform_device用它如何描述硬件信息

struct platform_drvier用它如何描述软件信息

总结:probe函数是否被调用,代表着软件和硬件是否结合!如果被调用,软件和硬件部分进行结合。

4.struct platform_device使用过程:

struct platform_device {

const char* name; //硬件节点的名称,必须指定初始化,此字段相当重要,用于软件和硬件的匹配!

int id; //硬件设备编号,如果只有一个设备,id为-1,如果有多个同名的设备,通过id来区分:0.1.2.3

struct devicedev;  //其实只关注一个字段成员void *platform_data,一般用这个字段来装载驱动开发人员自己定义的描述硬件信息的结构体(led_recource,btn_resource)

u32 num_resources; //用resource结构体描述的硬件信息的个数!

struct resource* resource;//用来装载设备的硬件信息,这个指向resource结构体描述的硬件信息 

const struct platform_device_id*id_entry; 

};

struct resource {//描述装载硬件信息

resource_size_t start; //硬件资源的起始信息

resource_size_t end;  //硬件资源的结束信息

const char *name; 

unsigned long flags;   //硬件资源的标识

struct resource *parent, *sibling, *child;

};

flags:硬件资源标识IORESOURCE_MEN:标识这个设备的访问向内存一样,是内存资源寄存器资源地址

IORESOURCE_IRQ:io资源,gpio的软件编号,中断号 

例如,描述GPH0_O

struct resource btn_res[]={

[0] ={

.start = 寄存器的起始地址;

.end =寄存器的结束地址;

.flags = IORESOURCE_MEN

},

[1] = {

.start = IRQ_EINT(0),

.end = IRQ_EINT(0),

.flags = IORESOURCE_IRQ

}

};

4.如何使用1.分配初始化硬件节点platform_device

struct platform_device led_dev ={

.name = "myled",

id = -1,

.resource = led_res,

.num_resources =ARRAY_SIZE(led_res),

.dev ={

.platform_data = 指向自己封装的硬件结构体

}

}; 

2.向内核的dev链表添加注册硬件节点platform_device_register(&led_drv);

1.帮你完成将硬件节点led_dev添加到链表中2.添加完毕以后,在帮你遍历链表,进行一系列的后续操作!

案例:编写led_dev.c实现led灯的硬件相关信息! 

5.struct platform_driver使用过程: 

struct platform_driver {

int (*probe)(struct platform_device *);

int (*remove)(struct platform_device *);

void (*shutdown)(struct platform_device *);

int (*suspend)(struct platform_device *, pm_message_t state);

int (*resume)(struct platform_device *);

struct device_driver driver;

const struct platform_device_id *id_table;

};

struct device_driver {

const char *name;

struct bus_type*bus; 

struct module *owner;

const char *mod_name;/* used for built-in modules */

}

probe函数:每当硬件和软件匹配成功,内核调用此函数;这个函数的调用意味 驱动的生命周期正式启动!

形参pdev指向匹配成功的硬件节点的首地址; 

remove函数:每当卸载软件或者硬件节点时,内核调用此函数,这个函数调用,意味驱动的生命周期结束! 

其他三个函数跟电源管理机制相关:shutdown:关闭系统,内核调用suspend:系统待机休眠resume:系统唤醒,内核调用

driver:成员只需关注其中的name,这个name用于匹配! 

使用L

1.分配初始化软件节点

struct platform_driver led_drv ={

.driver ={

.name = "myled" //必须写,用于匹配

},

.probe = led_probe,

.remove = led_remove

.suspend = led_suspend,//如果涉及电源管理

.resume = led_resume

}; 

2.向内核的drv链表添加软件节点platform_drvier_register(&led_drv);1.添加软件节点到drv链表

2.遍历dev链表,进行一系列的后续操作 

案例:编写led_drv.c来描述操作硬件的软件信息

 

案例:添加gpc0_4的支持!添加2个灯的支持

案例:不采用struct resource描述硬件信息 

//自己定义描述硬件的数据结构体

struct led_resource{

unsigned long addr; //寄存器的起始地址

int size; //寄存器的地址空间

int pin[2]; //gpio硬件编号

};

static struct led_resource led_info= {

.addr = 0xe0200080,

.size = 8,

.pin = {3,4}

};

led_drv.c

struct led_resource *pdata = pdev->dev.platform_data;

pdata->addr/pdata->size/pdata->pin[0]/pdata->pin[1]

 


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值