C/C++ 内存泄露定位方案

1.内存泄漏(memory leak)本质

内存泄露的本质其实就是malloc/new 和 free/delete 不匹配,或 重复释放。

2.预防内存泄漏

  1. 尽量从逻辑上保证每一个malloc/new 和 free/delete 相匹配;

  2. 对指针赋予地址之前,保证指针的指向为空;

  3. 对于结构化指针元素,释放这个指针元素之前,如果它的内部有指针,保证其内部的指针先被释放,再释放这个元素;

  4. 对于指针函数,保证有变量接收它的返回值。

3.怎么排查是否有内存泄漏

  1. 对于简单程序,可以直接在代码中排查,但是对于大型、复杂的程序,仅靠肉眼观察非常吃力;

  2. 用dlsym做函数拦截,将malloc/free替换

// 编译命令:gcc -shared -fPIC -ldl -o hook.so hook.c
// _GUN_SOURCE 必须放在最顶部,否则会报错 RTLD_NEXT 未定义
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>

// 定义函数指针类型
typedef void*(*malloc_func_t)(size_t);
static malloc_func_t malloc_f = NULL;

// free 同理
typedef void(*free_func_t)(void* p);
static free_func_t free_f = NULL;

// 通过符号抢占,先定义同名函数,将malloc hook住
void* malloc(size_t size) {
    printf("exec malloc.\n");
    if (!malloc_f) {
        // 获取下一个出现的 malloc 地址
        malloc_f = (malloc_func_t)dlsym(RTLD_NEXT, "malloc");
    }
    void *ptr = malloc_f(size);
    return ptr;
}
void free(void* p){
    printf("exec free.\n");
    if(!free_f){
        free_f = (free_func_t)dlsym(RTLD_NEXT,"free");
    }
    free_f(p);
}

int main(){
    void *ptr1 = malloc(10);
    void *ptr2 = malloc(20);
    void *ptr3 = malloc(30);
    
    free(ptr1);
    free(ptr3);
    
    return 0;
}

如上文件编译运行之后会出现Segmentation fault报错,原因是printf内部会调用malloc函数,而malloc函数被我们使用钩子hook了,会导致调用重载的malloc,再次进入printf,导致了一个很难以发现的递归。

// 编译命令:gcc -shared -fPIC -ldl -o hook.so hook.c
// _GUN_SOURCE 必须放在最顶部,否则会报错 RTLD_NEXT 未定义
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

// 定义函数指针类型
typedef void*(*malloc_func_t)(size_t);
static malloc_func_t malloc_f = NULL;

// free 同理
typedef void(*free_func_t)(void* p);
static free_func_t free_f = NULL;

bool malloc_enable = true;
bool free_f_enable = true;

int malloc_times,free_times;

// 通过符号抢占,先定义同名函数,将malloc hook住
void* malloc(size_t size) {
    if(malloc_enable){
        // 通过一个变量屏蔽
        malloc_enable = false;
        if (!malloc_f)
        {
            // 获取下一个出现的 malloc 地址,此处就将库函数中的 malloc 函数地址给到了malloc_f
            // 所以下文调用malloc_f就可以正确分配内存
            malloc_f = (malloc_func_t)dlsym(RTLD_NEXT, "malloc");
        }
        // 如果申请/释放次数很多,不方便观察,所以不使用打印,而是用数字计数。
        // printf("exec malloc.\n");
        malloc_enable = true;
    }
    void *p = malloc_f(size);
    malloc_times++;
    return p;
}
void free(void* p){
    if(free_f_enable){
        // 与上文同理
        free_f_enable = false;
        if (!free_f){
            // 与上文同理
            free_f = (free_func_t)dlsym(RTLD_NEXT, "free");
        }
        // printf("exec free.\n");
        free_f_enable = true;
    }
    free_times++;
    free_f(p);
}

int main(){
    void *ptr1 = malloc(10);
    void *ptr2 = malloc(20);
    void *ptr3 = malloc(30);
    
    free(ptr1);
    free(ptr3);

    printf("malloc times: %d\n", malloc_times);
    printf("free times: %d\n", free_times);

    return 0;
}

输出如下:

使用printf每次都打印的结果:
exec malloc.
exec malloc.
exec malloc.
exec free.
exec free.
只打印计数的结果:
malloc times: 3
free times: 2

可以发现,与我们的预期是符合的,即有三次申请内存空间,但只有两次释放,这样就可以知道是否出现了内存泄漏

4.如何定位内存泄漏精确位置

核心思想依旧是重载malloc/free(hook)

1.通过__builtin_return_adress()获取地址

即通过 __builtin_return_address 获取上级调用的函数的地址

1.获取分配内存的语句的地址

// __builtin_return_address 获取上级调用的退出的地址,可以设置为1级,也可以设置为2级...
// 打印调用malloc和free位置的信息。 这里打印地址  通过 addr2line 进行地址和行号转换
#define _GNU_SOURCE
#include <dlfcn.h>  //对应的头文件
#include <stdio.h>
#include <stdlib.h>

typedef void *(*malloc_t)(size_t size);
malloc_t malloc_f = NULL;

typedef void (*free_t)(void* p);
free_t free_f = NULL;

int enable_malloc_hook = 1;
int enable_free_hook = 1;

void * malloc(size_t size){
    if(enable_malloc_hook) //对第三方调用导致的递归进行规避
    {
        enable_malloc_hook = 0;
        //打印上层调用的地址,即什么地方调用了 malloc 函数
        void *carrer = __builtin_return_address(0);
        printf("exec malloc [%p ]\n", carrer );
        enable_malloc_hook = 1;
    }
    return malloc_f(size);
}

void free(void * p){
    if(enable_free_hook){
        enable_free_hook = 0;
        void *carrer = __builtin_return_address(0);
        printf("exec free [%p]\n", carrer);
        enable_free_hook = 1;
    }
    free_f(p);
}

//通过dlsym 对malloc和free使用前进行hook
static void init_malloc_free_hook(){
    //只需要执行一次
    if(malloc_f == NULL){
        malloc_f = dlsym(RTLD_NEXT, "malloc"); //除了RTLD_NEXT 还有一个参数RTLD_DEFAULT
    }
    
    if(free_f == NULL)
    {
        free_f =  dlsym(RTLD_NEXT, "free");
    }
}
int main()
{
    init_malloc_free_hook(); //执行一次即可

    void * ptr1 = malloc(10);
    void * ptr2 = malloc(20);
    void *ptr3 = malloc(30);
    free(ptr1);
    free(ptr3);

    return 0;
}

运行结果如下:

exec malloc [0x55ad9abe7822 ]
exec malloc [0x55ad9abe7830 ]
exec malloc [0x55ad9abe783e ]
exec free [0x55ad9abe784e]
exec free [0x55ad9abe785a]

再使用addr2line 获取到地址对应的文件、函数以及行数

-f function 表示获取函数

-e executable 后指定可执行文件

-a address 指定地址

现代 Linux 系统默认编译时会生成 PIE (Position Independent Executable 位置独立可执行文件)可执行文件(gcc默认启用选项 -pie),这意味着每次程序运行时会以随机的不同的基地址被加载,程序中所有输出的地址都是相对这个基地址的偏移量。

addr2line -f -e demo -a 0x55ad9abe7830

此处提供 0x55ad9abe7830 无法正确获取结果
输出如下:
0x000055ad9abe7830
??
??:0

要想获得正确结果需要执行下面步骤(此处我的基地址为0x5ee0f8586000,基址获取比较麻烦,后文有讲解):
offset=$(printf "0x%x" $((0x5ee0f8586830 - 0x5ee0f8586000)))
addr2line -e demo -a $offset

(或者自己手动计算:addr2line -e demo -a 0x830)

这样可以得到正确结果:
0x0000000000000830
/home/he/path/demo.c:56

此时虽然能获得地址对应的文件、函数、行数,但是还是不能知道是哪一行出现了内存泄漏,接下来就解决这个问题

2.只保留未释放的指针的分配语句地址

// malloc和free 之间的关联是申请内存的地址,以该地址作为基准就可以一一对应
// malloc时写入一个文件,打印行数等必要信息  free时删除这个文件 通过有剩余文件判断内存泄露
#define _GNU_SOURCE
#include <dlfcn.h>  //对应的头文件
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

typedef void *(*malloc_t)(size_t size);
malloc_t malloc_f = NULL;

typedef void (*free_t)(void* p);
free_t free_f = NULL;

int enable_malloc_hook = 1;
int enable_free_hook = 1;

#define MEM_FILE_LENGTH 40
void * malloc(size_t size){
    if(enable_malloc_hook) //对第三方调用导致的递归进行规避
    {
        enable_malloc_hook = 0;
        //实际的内存申请,根据该地址写文件和free 相互关联
        void *ptr =malloc_f(size);
        //打印上层调用的地址
        void *carrer = __builtin_return_address(0);
        printf("exec malloc [%p ]\n", carrer );
        
        //通过写入文件的方式 对malloc和free进行关联  malloc时写入文件
        char file_buff[MEM_FILE_LENGTH] = {0};
        // 文件名以指针地址命名:0x????????.mem,在释放时传入参数为指针,顺便通过指针删除,无需额外参数
        sprintf(file_buff, "./mem/%p.mem", ptr);
        
        //打开文件写入必要信息 使用前创建目录级别(此处文件名存放在file_buff中)
        FILE *fp = fopen(file_buff, "w");
        fprintf(fp, "[malloc addr : +%p ] ---->mem:%p  size:%lu \n",carrer, ptr, size);
        fflush(fp); //刷新写入文件
        
        enable_malloc_hook = 1;
        return ptr;
    }else
    {
         return malloc_f(size);
    }
}

void free(void * p){
    if(enable_free_hook){
        enable_free_hook = 0;
        void *carrer = __builtin_return_address(0);
        
        //free时删除文件  根据剩余文件判断内存泄露
        char file_buff[MEM_FILE_LENGTH] = {0};
        sprintf(file_buff, "./mem/%p.mem", p);
        //删除文件 根据malloc对应的指针
        if(unlink(file_buff) <0)
        {
            printf("double free: %p, %p \n", p, carrer);
        }
        //这里的打印实际就没意义了
        printf("exec free [%p]\n", carrer);
        free_f(p);
        enable_free_hook = 1;
    }else
    {
        free_f(p);
    }
}

//通过dlsym 对malloc和free使用前进行hook
static void init_malloc_free_hook(){
    //只需要执行一次
    if(malloc_f == NULL){
        malloc_f = dlsym(RTLD_NEXT, "malloc"); //除了RTLD_NEXT 还有一个参数RTLD_DEFAULT
    }
    
    if(free_f == NULL)
    {
        free_f =  dlsym(RTLD_NEXT, "free");
    }
    return ;
}
int main()
{
    
    init_malloc_free_hook(); //执行一次
    void * ptr1 = malloc(10);
    void * ptr2 = malloc(20);
    
    free(ptr1);
    
    void * ptr3 = malloc(30);
    free(ptr3);
    return 0;
}

这样,上面的程序运行之后如果在目录下的mem文件夹中有文件,就说明出现了内存泄露,打开文件,文件内容形如这样,指明了分配内存的语句在可执行文件中的地址,分配的地址值以及大小:

[malloc addr : +0x56040ab28b89 ] ---->mem:0x56040b6488d0  size:20 

此时如果你的可执行文件没有使用-pie选项,在 addr2line 中使用分配此内存的调用位置在文件中的地址(第一个地址)即可得到语句在程序中的精确位置:

he@ubuntu:~/path/memoryLeak$ addr2line -fe demo -a 0x56040ab28b89
main
/home/he/path/memoryLeak/demo.c:88

3.获取基地址以计算精确位置

如果你的可执行文件使用的 -pie 选项并且不想修改配置文件,按照下面的方案可以解决

1.修改编译选项

编译时使用 -no-pie 就可以直接使用addr2line获取具体文件、函数、行数:

gcc -o a.out a.c -g -no-pie
2.程序运行时间足够长

可以通过查看 /proc/<pid>/maps 获取基地址:

# 替换 <pid> 为实际进程 ID ,用你的可执行程序替换<your_program>
cat /proc/<pid>/maps | grep '<your_program>'
3.程序运行时间较短

通过GDB获取基地址:

用你的可执行程序替换第一行的<your_program>

he@ubuntu:~/path/memoryLeak$ gdb ./<your_program> -ex "starti" -ex "info proc mappings" -ex "q"
... 省略一长段此处不需要的输出...
---Type <return> to continue, or q <return> to quit---

Program stopped.
0x00007ffff7dd4090 in _start () from /lib64/ld-linux-x86-64.so.2
process 13227
Mapped address spaces:

          Start Addr           End Addr       Size     Offset objfile
      0x555555554000     0x555555555000     0x1000        0x0 /home/he/path/memoryLeak/demo
      0x555555755000     0x555555757000     0x2000     0x1000 /home/he/path/memoryLeak/demo
      0x7ffff7dd3000     0x7ffff7dfc000    0x29000        0x0 /lib/x86_64-linux-gnu/ld-2.27.so
      0x7ffff7ff7000     0x7ffff7ffa000     0x3000        0x0 [vvar]
      0x7ffff7ffa000     0x7ffff7ffc000     0x2000        0x0 [vdso]
      0x7ffff7ffc000     0x7ffff7ffe000     0x2000    0x29000 /lib/x86_64-linux-gnu/ld-2---Type <return> to continue, or q <return> to quit---
.27.so
      0x7ffff7ffe000     0x7ffff7fff000     0x1000        0x0 
      0x7ffffffde000     0x7ffffffff000    0x21000        0x0 [stack]
  0xffffffffff600000 0xffffffffff601000     0x1000        0x0 [vsyscall]
A debugging session is active.

        Inferior 1 [process 13227] will be killed.

Quit anyway? (y or n) 
Please answer y or n.
A debugging session is active.

        Inferior 1 [process 13227] will be killed.

Quit anyway? (y or n) y

上面输出的Mapped address spaces字段中有很多,我们只需要Start Addr的第一个,我这里是0x555555554000

使用计算器(手算也可以:( )用mem目录下的文件中第一个地址(绝对地址:0x56040ab28b89 )减去基地址(0x555555554000)得到相对地址(0x17F7050B89),再将这个相对地址作为addr2line的参数即可得到内存泄漏的位置(这里有一个很奇怪的问题,如果我用0x17F7050B89作为参数依然得不到正常结果,但是写成加上空格的形式:0x17 F705 0B89才能得到结果,虽然输出依旧有点问题,有懂为什么的大佬可以指点一下):

he@ubuntu:~/path/memoryLeak$ addr2line -fe demo -a 0x17 F705 0B89
0x0000000000000017
??
??:0
0x000000000000f705
??
??:0
0x0000000000000b89
main
/home/he/path/memoryLeak/demo.c:88

2.使用宏定义替换malloc/free,__LINE__获取位置

先实现我们自定义的malloc_hook/free_hook函数,在宏里面预先填好两个参数__FILE__和__LINE__,用自定义的替换原来的malloc和free,即可看见文件和行数

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

// 这两个宏不能放在这里 会对malloc_hook 和 free_hook 内部实际调用的也替换,
// 形成递归调用 并且无法规避 会报错Segmentation fault
// #define malloc(size)  malloc_hook(size, __FILE__, __LINE__)
// #define free(p)       free_hook(p,  __FILE__, __LINE__)

#define MEM_FILE_LENGTH 40
//实现目标函数
void *malloc_hook(size_t size, const char* file, int line)
{
    //这里还是通过文件的方式进行识别
    void *ptr =malloc(size);
    char file_name_buff[MEM_FILE_LENGTH] = {0};
    sprintf(file_name_buff, "./mem/%p.mem", ptr);
        
    //打开文件写入必要信息 使用前创建目录级别
    FILE *fp = fopen(file_name_buff, "w");
    fprintf(fp, "[file:%s  line:%d ] ---->mem:%p  size:%lu \n",file, line, ptr, size);
    fflush(fp); //刷新写入文件
    return ptr;
}

void free_hook(void *p, const char* file, int line)
{
    char file_name_buff[MEM_FILE_LENGTH] = {0};
    sprintf(file_name_buff, "./mem/%p.mem", p);
    
    if(unlink(file_name_buff) <0)
    {
        printf("double free: %p, file: %s. line :%d \n", p, file, line);
    }
    free(p);
}
//宏定义实现代码中调用malloc/free时调用我们目标函数
#define malloc(size)    malloc_hook(size, __FILE__, __LINE__)
#define free(p)         free_hook(p,  __FILE__, __LINE__)

int main()
{
    void * ptr1 = malloc(10);
    void * ptr2 = malloc(20);
    
    free(ptr1);
    
    void * ptr3 = malloc(30);
    free(ptr3);
    return 0;
}

这样的方式很简单即可得到最后的结果,依然是在mem目录下查看文件,如果有就发生了内存泄漏,反之就没有发生。

[file:macro_hook1.c  line:43 ] ---->mem:0x5571bf8ac4c0  size:20 

可以很直观的看到内存泄漏发生在哪个文件和行数。

3.劫持malloc,用_libc_malloc申请内存

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
//实际内存申请的函数
extern void *__libc_malloc(size_t size);
int enable_malloc_hook = 1;

extern void __libc_free(void* p);
int enable_free_hook = 1;

// func --> malloc() { __builtin_return_address(0)}
// callback --> func --> malloc() { __builtin_return_address(1)}
// main --> callback --> func --> malloc() { __builtin_return_address(2)}

//calloc, realloc
void *malloc(size_t size) {

    if (enable_malloc_hook) {
        enable_malloc_hook = 0;

        void *p = __libc_malloc(size); //重载达到劫持后 实际内存申请
        void *caller = __builtin_return_address(0); // 0
        
        char buff[128] = {0};
        sprintf(buff, "./mem/%p.mem", p);

        FILE *fp = fopen(buff, "w");
        fprintf(fp, "[+%p] --> addr:%p, size:%ld\n", caller, p, size);
        fflush(fp);

        //fclose(fp); //free
        
        enable_malloc_hook = 1;
        return p;
    } else {
        return __libc_malloc(size);
    }
    
    return NULL;
}

void free(void *p) {
    if (enable_free_hook) {

        enable_free_hook = 0;

        char buff[128] = {0};
        sprintf(buff, "./mem/%p.mem", p);

        if (unlink(buff) < 0) { // no exist
            printf("double free: %p\n", p);
        }
        
        __libc_free(p);

        // rm -rf p.mem
        enable_free_hook = 1;

    } else {
        __libc_free(p);
    }
}

int main()
{
    void * ptr1 = malloc(10);
    void * ptr2 = malloc(20);
    
    free(ptr1);
    
    void * ptr3 = malloc(30);
    free(ptr3);
    return 0;
}

获取具体内存泄漏位置同上,不再赘述,下文同样省略。

4.mem_trace劫持malloc/free

#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <malloc.h>

/* #include <malloc.h>
 void *(*__malloc_hook)(size_t size, const void *caller);
 void *(*__realloc_hook)(void *ptr, size_t size, const void *caller);
 void *(*__memalign_hook)(size_t alignment, size_t size,
                          const void *caller);
 void (*__free_hook)(void *ptr, const void *caller);
 void (*__malloc_initialize_hook)(void);
 void (*__after_morecore_hook)(void);*/
// #include <mcheck.h>

typedef void *(*malloc_hook_t)(size_t size, const void *caller);
typedef void (*free_hook_t)(void *p, const void *caller);

malloc_hook_t old_malloc_f = NULL;
free_hook_t old_free_f = NULL;
int replaced = 0;

void mem_trace(void);
void mem_untrace(void);

void *malloc_hook_f(size_t size, const void *caller) {

    mem_untrace();
    void *ptr = malloc(size);
    //printf("+%p: addr[%p]\n", caller, ptr);
    char buff[128] = {0};
    sprintf(buff, "./mem/%p.mem", ptr);

    FILE *fp = fopen(buff, "w");
    fprintf(fp, "[+%p] --> addr:%p, size:%ld\n", caller, ptr, size);
    fflush(fp);

    fclose(fp); //free
    mem_trace();
    return ptr;
}

void *free_hook_f(void *p, const void *caller) {

    mem_untrace();
    //printf("-%p: addr[%p]\n", caller, p);

    char buff[128] = {0};
    sprintf(buff, "./mem/%p.mem", p);

    if (unlink(buff) < 0) { // no exist
        printf("double free: %p\n", p);
        return NULL;
    }
    free(p);
    mem_trace();
}
//对__malloc_hook 和__free_hook 重赋值 
void mem_trace(void) { //mtrace
    replaced = 1;      
    old_malloc_f = __malloc_hook; //malloc --> 
    old_free_f = __free_hook;

    __malloc_hook = malloc_hook_f;
    __free_hook = free_hook_f;
}

//还原 __malloc_hook 和__free_hook
void mem_untrace(void) {
    __malloc_hook = old_malloc_f;
    __free_hook = old_free_f;
    replaced = 0;
}

int main()
{
    mem_trace();  //mtrace();    //进行hook劫持
    void * ptr1 = malloc(10);
    void * ptr2 = malloc(20);
    
    free(ptr1);
    
    void * ptr3 = malloc(30);
    free(ptr3);
    mem_untrace();  //muntrace(); //取消劫持
    return 0;
}

工作原理:

  1. 通过 __malloc_hook和__free_hook这两个Glibc提供的钩子函数,拦截所有malloc和free调用

  2. 每次malloc时,会在mem目录下创建一个以地址命名的文件,记录分配大小和调用位置

  3. 每次free时,会删除对应的内存记录文件

  4. 如果检测到重复释放(double free),会打印警告

代码结构:

  1. mem_trace():启用hook,保存原始hook函数指针;

  2. mem_untrace():恢复原始hook函数指针;

  3. malloc_hook_f():malloc的hook实现;

  4. free_hook_f(): free的hook实现。

5.使用valgrind检测内存泄漏

valgrind --leak-check=full --show-leak-kinds=all --log-file=<filename> ./your_program

1.常用参数及释义

1.--leak-check=<mode>

  • 控制内存泄漏检测级别:

    • no:不检测

    • summary(默认):仅显示泄漏数量

    • yes / full:显示每个泄漏的详细栈回溯

一般来说使用full或者yes选项

2.--show-leak-kinds=<kinds>

  • 指定要显示的泄漏类型(使用逗号分隔):

    • definite:明确泄漏(指针永久丢失)

    • possible:可能泄漏(如指针位于内存块中间)

    • indirect:间接泄漏(通过其他泄漏内存访问)

    • reachable:程序结束前仍可达(未释放但指针未丢失)

一般使用all选项

3.--track-origins=yes

  • 跟踪未初始化值的来源(如使用未初始化的变量)

直接启用

4.--log-file=<file>

  • 输出重定向到文件(默认输出到 stderr)

生成的结果较长可以开启

2.结果的分析

如果使用了 --show-leak-kinds=all ,那么结果中就会出现下面所有的字段(没有泄露的即为0),对各字段的释义:

  • definitely lost:程序一定存在内存泄露;

  • indirectly lost:间接泄漏(通常由直接泄漏引起),泄露情况和指针结构相关;

  • possibly lost:指针指向内存块中间(可能会误报)

  • still reachable:程序结束前未释放(需评估是否合理),没有释放掉一些本可以释放的内存;

  • suppressed :意味着有些泄露信息被压抑文件压抑了,在默认的 suppression 文件中可以看到相关设置;

  • 可能会出现Invalid read/write:内存越界访问(严重错误)。

6.声明

文中如有错误欢迎指出。本文仅供个人学习记录、不具有任何其他用途,若有侵权联系删除。

部分参考帖子:

https://zhuanlan.zhihu.com/p/458541056

https://zhuanlan.zhihu.com/p/494468532

https://zhuanlan.zhihu.com/p/15101814919

https://blog.youkuaiyun.com/qq_38393271/article/details/121537669

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值