定位多线程内存越界问题实践总结

深入探究 Linux C中进程崩溃 目录 前言 栈内存异常以及案列分享 堆内存异常以及案列分享 Ptmalloc的内存管理 前言 LinuxC的内存管理融入了设计者很多高效的算法和思想,提供了用户直接可以操作的内存的“指针”,这也是其他语言不涉及的(除Go语言21世纪的C语言),但是对于指针这把双刃剑,开发者操作着进程的内存,难免造成意想不到的崩溃问题,其实踩内存情况应该把栈和堆(ptmalloc)分开,因为linux对这两块的管理... 阅读详情

定位多线程内存越界问题实践总结

2013/2/4

杨志丰 yangzhifeng83@gmail.com

 

关键字 多线程,内存越界,valgrind,electric-fence,mprotect,libsigsegv,glibc

 

最近定位了在一个多线程服务器程序(OceanBase MergeServer)中,一个线程非法篡改另一个线程的内存而导致程序core掉的问题。定位这个问题花了整整一周的时间,期间历经曲折,尝试了各种内存调试的办法。往往感觉就要柳暗花明了,却发现又进入了另一个死胡同。最后,使用强大的mprotect+backtrace+libsigsegv等工具成功定位了问题。整个定位过程遇到的问题和解决办法对于多线程内存越界问题都很典型,简单总结一下和大家分享。只对终极组合秘技感兴趣的同学,请直接阅读最后一节,其他的章节写到这里是为了科普。

现象

core是在系统集成测试过程中发现的。服务器程序MergeServer有一个50个工作线程组成的线程池,当使用8个线程的测试程序通过MergeServer读取数据时,后者偶尔会core掉。用gdb查看core文件,发现core的原因是一个指针的地址非法,当进程访问指针指向的地址时引起了段错误(segment fault)。见下图。

发生越界的指针ptr_位于一个叫做cname_的对象中,而这个对象是一个动态数组field_columns_的第10个元素的成员。如下图。

复现问题

之后,花了2天的时间,终于找到了重现问题的方法。重现多次,可以观察到如下一些现象:

1. 随着客户端并发数的加大(从8个线程到16个线程),出core的概率加大;

2. 减少服务器端线程池中的线程数(从50个到2个),就不能复现core了。

3. 被篡改的那个指针,总是有一半(高4字节)被改为了0,而另一半看起来似乎是正确的。

4. 请看前一节,重现多次,每次出core,都是因为field_columns_这个动态数组的第10个元素data_[9]的cname_成员的ptr_成员被篡改。这是一个不好解释的奇怪现象。

5. 在代码中插入检查点,从field_columns_中内容最初产生到读取导致越界的这段代码序列中“埋点”,既使用二分查找法定位篡改cname_的代码位置。结果发现,程序有时core到检查点前,有时又core到检查点后。

综合以上现象,初步判断这是一个多线程程序中内存越界的问题。

使用glibc的MALLOC_CHECK_

因为是一个内存问题,考虑使用一些内存调试工具来定位问题。因为OB内部对于内存块有自己的缓存,需要去除它的影响。修改OB内存分配器,让它每次都直接调用c库的malloc和free等,不做缓存。然后,可以使用glibc内置的内存块完整性检查功能。

使用这一特性,程序无需重新编译,只需要在运行的时候设置环境变量MALLOC_CHECK_(注意结尾的下划线)。每当在程序运行过程free内存给glibc时,glibc会检查其隐藏的元数据的完整性,如果发现错误就会立即abort。

用类似下面的命令行启动server程序:

export MALLOC_CHECK_=2

bin/mergeserver -z 45447 -r 10.232.36.183:45401 -p45441

使用MALLOC_CHECK_以后,程序core到了不同的位置,是在调用free时,glibc检查内存块前面的校验头错误而abort掉了。如下图。

但这个core能带给我们想信息也很少。我们只是找到了另外一种稍高效地重现问题的方法而已。或许最初看到的core的现象是延后显现而已,其实“更早”的时刻内存就被破坏掉了。

valgrind

glibc提供的MALLOC_CHECK_功能太简单了,有没有更高级点的工具不光能够报告错误,还能分析出问题原因来?我们自然想到了大名鼎鼎的valgrind。用valgrind来检查内存问题,程序也不需要重新编译,只需要使用valgrind来启动:

nohup valgrind --error-limit=no --suppressions=suppress bin/mergeserver -z 45447 -r 10.232.36.183:45401 -p45441 >nohup.out &

默认情况下,当valgrind发现了1000中不同的错误,或者总数超过1000万次错误后,会停止报告错误。加了--error-limit=no以后可以禁止这一特性。--suppressions用来屏蔽掉一些不关心的误报的问题。

经过一翻折腾,用valgrind复现不了core的问题。valgrind报出的错误也都是一些与问题无关的误报。大概是因为valgrind运行程序大约会使程序性能慢10倍以上,这会影响多线程程序运行时的时序,导致core不能复现。此路不通。

magic number

既然MALLOC_CHECK_可以检测到程序的内存问题,我们其实想知道的是谁(哪段代码)越了界。此时,我们想到了使用magic number填充来标示数据结构的方法。如果我们在被越界的内存中看到了某个magic number,就知道是哪段代码的问题了。

首先,修改对于malloc的封装函数,把返回给用户的内存块填充为特殊的值(这里为0xEF),并且在开始和结束部分各多申请24字节,也填充为特殊值(起始0xBA,结尾0xDC)。另外,我们把预留内存块头部的第二个8字节用来存储当前线程的ID,这样一旦观察到被越界,我们可以据此判定是哪个线程越的界。代码示例如下。

然后,在用户程序通过我们的free入口释放内存时,对我们填充到边界的magic number进行检查。同时调用mprobe强制glibc对内存块进行完整性检查。

最后,给程序中所有被怀疑的关键数据结构加上magic number,以便在调试器中检查内存时能识别出来。例如

好了,都加好了。用MALLOC_CHECK_的方式重新运行。程序如我们所愿又core掉了,检查被越界位置的内存:

如上图,红色部分是我们自己填充的越界检查头部,可以看到它没有被破坏。其中第二行存储的线程号经过确认确实等于我们当前线程的线程号。蓝色部分为前一个动态内存分配的结尾,也是完整的(24个字节0xdc)。0x44afb60和0x44afb68两行所示的内存为glibc malloc存储自身元数据的地方,程序core掉的原因是它检查这两行内容的完整性时发现了错误。由此推断,被非法篡改的内容小于16个字节。仔细观察这16字节的内容,我们没有看到熟悉的magic number,也就无法推知有bug的代码是哪块。这和我们最初发现的core的现象相互印证,很可能被非法修改的内容仅为4个字节(int32_t大小)。

另外,虽然我们加宽了检查边界,程序还是会core到glibc malloc的元数据处,而不是我们添加的边界里。而且,我们总可以观察到前一块内存(图中蓝色所示)的结尾时完整的,没被破坏。这说明,这不是简单的内存访问超出边界导致的越界。我们可以大胆的做一下猜测:要么是一块已经释放的内存被非法重用了;要么这是通过野指针“空投”过来的一次内存修改。

如果我们的猜测是正确的,那么我们用这种添加内存边界的方式检查内存问题的方法几乎必然是无效的。

打怪利器electric-fence

至此,我们知道某个时间段内某个变量的内存被其他线程非法修改了,但是却无法定位到是哪个线程哪段代码。这就好比你明明知道未来某个时间段在某个地点会发生凶案,却没办法看到凶手。无比郁闷。

有没有办法能检测到一个内存地址被非法写入呢?有。又一个大名鼎鼎的内存调试库electric-fence(简称efence)就华丽登场了。使用MALLOC_CHECK_或者magic number的方式检测的最大问题是,这种检查是“事后”的。在多线程的复杂环境中,如果不能发生破坏的第一时间检查现场,往往已经不能发现罪魁祸首的蛛丝马迹了。

electric-fence利用底层硬件(CPU提供的虚拟内存管理)提供的机制,对内存区域进行保护。实际上它就是使用了下一节我们要自己编码使用的mprotect系统调用。当被保护的内存被修改时,程序会立即core掉,通过检查core文件的backtrace,就容易定位到问题代码。

这个库的版本有点混乱,容易弄错。搜索和下载这个库时,我才发现,electric-fence的作者也是大名鼎鼎的busybox的作者,牛人一枚。原作者的官网上的下载地址为http://perens.com/FreeSoftware/ElectricFence/。但是,这个版本在linux上编译连接到我的程序的时候会报WARNING,而且后面执行的时候也会出错。后来,找到了debian提供的一个更高版本的库,估计是社区针对linux做了改进。我最后用的是这个2.2.4版本:http://packages.debian.org/sid/electric-fence。

使用efence需要重新编译程序。efence编译后提供了一个静态库libefence.a,它包含了能够替代glibc的malloc, free等库函数的一组实现。编译时需要一些技巧。首先,要把-lefence放到编译命令行其他库之前;其次,用-umalloc强制g++从libefence中查找malloc等本来在glibc中包含的库函数:

g++ -umalloc –lefence …

用strings来检查产生的程序是否真的使用了efence:

和很多工具类似,efence也通过设置环境变量来修改它运行时的行为。通常,efence在每个内存块的结尾放置一个不可访问的页,当程序越界访问内存块后面的内存时,就会被检测到。如果设置EF_PROTECT_BELOW=1,则是在内存块前插入一个不可访问的页。通常情况下,efence只检测被分配出去的内存块,一个块被分配出去后free以后会缓存下来,直到一下次分配出去才会再次被检测。而如果设置了EF_PROTECT_FREE=1,所有被free的内存都不会被再次分配出去,efence会检测这些被释放的内存是否被非法使用(这正是我们目前怀疑的地方)。但因为不重用内存,内存可能会膨胀地很厉害。

我使用上面2个标记的4种组合运行我们的程序,遗憾的是,问题无法复现,efence没有报错。另外,当EF_PROTECT_FREE=1时,运行一段时间后,MergeServer的虚拟内存很快膨胀到140多G,导致无法继续测试下去。又进入了一个死胡同。

终极神器mprotect + backtrace + libsigsegv

electric-fence的神奇能力实际上是使用系统调用mprotect实现的。mprotect的原型很简单,

int mprotect(const void *addr, size_t len, int prot);

mprotect可以使得[addr,addr+len-1]这段内存变成不可读写,只读,可读写等模式,如果发生了非法访问,程序会收到段错误信号SIGSEGV。但mprotect有一个很强的限制,要求addr是页对齐的,否则系统调用返回错误EINVAL。这个限制和操作系统内核的页管理机制相关。

 

如图,我们已经知道这个动态数组的第10个元素会被非法越界修改。review了代码,发现从这个数组内容初始化完毕以后,到使用这个数组内容这段时间,不应该再有修改操作。那么,我们就可以在数组内容被初始化之后,立即调用mprotect对其进行只读保护。

尝试一

因为mprotect要求输入的内存地址页对齐,所以我修改了动态数组的实现,每次申请内存块的时候多分配一个页大小,然后取页对齐的地址为第一个元素的起始位置。

如上图,浅蓝色部分为为了对齐内存地址而做的padding。代码见下

动态数组申请的最小内存块的大小为64KB。这里,动态数组中每个元素的大小为80字节,我们只需要从第1个元素开始保护一个页的大小即可:

既然这个保护区域是程序中自动插入的,需要在内存释放给系统前回复它为可读写,否则必然会因mprotect产生段错误。

好了,编译、重启、运行重现脚本。悲剧了。程序运行了很久都不再出core了,无法复现问题。我们在分配动态数组内存时,为了对齐在内存块前添加的padding导致程序运行时的内存分布和原来产生core的运行环境不同了。这可能是无法复现的原因。要想复现,我们不能破坏原来的内存分配方式。

尝试二

不改变动态数组的内存块申请方式,又要满足mprotect保护的地址必须页对齐的要求,怎么做呢?我们换一个思路,从第10个元素向前,找到包含它且离它最近的页对齐的内存地址。如下图

 

但这样会造成一个问题。图中浅蓝色部分本不是这个动态数组对象所拥有的内存,它可能被其他任何线程的任何数据结构在使用。我们使用这种方式保护红色区域,会有很多无关的落入蓝色区域的修改操作导致mprotect产生段错误。

实验了一下,果然,程序跑起来不久就在其他无关的代码处产生了段错误。这种保护方式的代码如下:

成功

在上一节的保护方式下,我们因为保护了无关内存区域,会导致程序过早产生SIGSEGV而退出。我们能否截获信号,不让程序在非法访问mprotect保护区域后仍然能继续执行呢?当然。我们可以定制一个SIGSEGV段错误信号的处理函数。在这个处理函数中,如果能打印段错误时候的当前调用栈,就可以找到罪魁祸首了。

代码如上图。注意,处理SIGSEGV的handler函数有一些小技巧(坑很多):

1. SIGSEGV一般是内核处理的(page fault)。使用库libsigsegv可以简化用户空间撰写处理函数的难度。

2. 处理函数中,不能调用任何可能再分配内存的函数,否则会引起double fault。例如,在这段处理函数中,使用open系统调用打开文件,不能使用fopen;buff是从栈上分配的,不能从heap上申请;不能使用backtrace_symbols,它会向glibc动态申请内存,而要使用安全的backtrace_symbols_fd把backtrace直接写入文件。

3. 最重要的,在SIGSEGV的处理函数中,我们需要恢复引起段错误的内存块为可读写的。这样,当处理函数返回被中断的代码继续执行时,才不能再次引起段错误。

 

重新编译代码,运行重现脚本。查看记录了backtrace的文件sigsegv.bt,我们看到了熟悉的被篡改的指针地址(一半为0):

这个段错误会最终导致程序core掉,因为这个SIGSEGV信号不是由我们使用mprotect的保护而产生的。查看core文件,可以查到被越界的内存(即ptr_)的地址。从sigsegv.bt文件中查找,果然找到了那一次非法访问:

使用addr2line检查上面这个调用栈中的地址,我们终于找到了它。又经过一番代码review和验证,才总算确定了错误原因。有一个动态new出来的对象的指针在两个有关联的线程中共享,在某种极端情况下,其中一个delete了对象之后,另一个线程又修改了这个对象。

小结

小结一下,遇到棘手的内存越界问题,可以使用下面顺序逐个尝试:

1. code review分析代码。

2. valgrind用起来最简单,几乎是傻瓜式的。能用尽量用。

3. glibc的MALLOC_CHECK_使用起来和很简单,不需要重现编译代码。可以用来发现问题,但是其本身无法定位问题。和magic number结合起来,可以用来定位一类内存越界的问题。

4. 和electric-fence齐名的还有一个内存调试库叫做dmalloc。虽然在本次解决问题的过程中没有用到,这个库对于检测内存泄露等其他问题很有用。推荐大家学习一下,放到自己的工具库中。

5. electric-fence是定位一类“野指针”访问问题的利器,强烈推荐使用。

6. 如果上述所有工具都帮不了你,那么只好在熟悉代码逻辑的基础上,使用终极武器了。

7. code review。通过尝试代码库中不同版本编译出来的程序复现bug,用二分法定位引入bug的最早的一次代码提交。

苦逼C++程序员告诫新人,除非要做性能要求特别苛刻的底层系统,否则还是在你的项目中使用java吧。


gdb结合coredump定位崩溃进程 Linux环境下经常遇到某个进程挂掉而找不到原因,我们可以通过生成core file文件加上gdb来定位。   linux下如何产生core file? 我们可以使用ulimit这条命令对core file文件的大小进行设定。 一般默认情况下,core file的大小被设置为了0,这样系统就不dump出core file了。 这时用如下命令进行设置: ulimit 阅读详情

相关推荐

Windows10开机无限蓝屏(SrtTrail.txt)

这个问题已经有很多的大佬发过解决办法了,这里仅仅作为自己日后方便查找所写文章 无法开机——自动修复失败,报SrtTrail.txt文件错误,且陷入无限蓝屏的循环 事故 故事的起因是这样的: 开开心心的组装电脑,花了几天时间来配置,然后发现USB3.0的接口太少,于是在某宝上搞到一张pcie转7口USB3.0的转接卡,往电脑上一插 插电,开机,电流轻松跑上去 然后,它和原来的磁盘阵列卡冲突,跳过阵列卡自检直接进入系统,boom,就导致了这场悲剧 解决方法 在蓝屏的界面找到:疑难解答->通过命令提示符 使

Holo__的博客 3万+

malloc崩溃

malloc崩溃

yunlianglinfeng的专栏 7164

DP3(3D Diffusion Policy)算法详解:从点云感知到扩散动作生成

DP3 的全称是:DP3 = 3D Diffusion Policy\boxed{\text{DP3 = 3D Diffusion Policy}}DP3 = 3D Diffusion Policy​对应论文题目是:3D Diffusion Policy: Generalizable Visuomotor Policy Learning via Simple 3D Representations该工作发表于 Robotics: Science and Systems(RSS)2024。DP3 是一种面向机器

u013250861的博客 355

Linux 进程(一):环境及其控制

进程环境 main启动 当内核执行C程序时,在调用main前先调用一个特殊的启动例程。可执行程序将此启动例程指定为程序的起始地址,接着启动例程从内核中取出命令行参数和环境变量值,然后执行main函数。 进程终止 使进程终止的方式有8种,其中5种为正常终止,3种为异常终止: 终止类型 说明 正常终止 (1) 从main返回 ...

weixin_34138521的博客 212

STM32CubeMX的正交编码器encoder

由于最近干活遇到需要获取正交编码的功能,所以返回来研究一下STM32CubeMX的编码器功能,之前也更新过STM32的定时器功能,但是因为之前搞平衡车的正交编码器没有搞清楚原理,就直接用了脉冲输入功能草草了事,现在也算是为之前的年轻买单了,不过看到现有的Encoder说明都比较简单,所以就当是为开发者做贡献了,我研究清楚之后来一篇博客。

ASWaterbenben的博客 1万+

【原创】Ubuntu20.04 安装 Isaac Gym 仿真器

Ubuntu20.04 安装 Isaac Gym

qq_54900679的博客 3094

C语言中的abort函数

/*name : abortfunction : 异常终止一个进程declare : void abort(void);include :#include explanation:abort函数是一个比较严重的函数,当调用它时,会导致程序异常终止,而不会进行一些常规的清除工作,比如释放内存等。*///example:#include

麦克船长的技术笔记 1万+

从12摄像头到纯视觉:小鹏AI鹰眼方案如何用LOFIC技术省掉激光雷达?

本文深入解析小鹏汽车AI鹰眼方案如何通过LOFIC技术实现纯视觉自动驾驶,替代传统激光雷达。LOFIC摄像头在动态范围、噪声控制和HDR优化方面表现卓越,结合800万像素双目广角摄像头,达到激光雷达级测距精度。该方案不仅降低成本,还提升系统可靠性和用户体验,展示了纯视觉路线的技术潜力与边界。

t8u9v0w1x的博客 935

linux下定位多线程内存越界问题实践总结

近定位了在一个多线程服务器程序(OceanBase MergeServer)中,一个线程非法篡改另一个线程的内存而导致程序core掉的问题。定位这个问题历经曲折,尝试了各种内存调试的办法。往往感觉就要柳暗花明了,却发现又进入了另一个死胡同。最后,使用强大的mprotect+backtrace+libsigsegv等工具成功定位了问题。整个定位过程遇到的问题和解决办法对于多线程内存越界问题都很典型,简单总结一下和大家分享。 现象 core是在系统集成测试过程中发现的。服务器程序MergeServer有一个

zz460833359的博客 1301

UNet改进(52):轴向+代理注意力让UNet既轻快又精准

本文提出了一种改进的UNet架构,通过引入轴向注意力代理模块增强医学图像分割性能。该模块将二维自注意力分解为行列两个一维注意力操作,结合可学习代理节点进行全局信息聚合,在保持计算效率的同时提升模型对长距离依赖的建模能力。实验表明,该方法在多个医学图像数据集上较传统UNet提升3.2%-5.7%的Dice系数,边界质量显著改善。该方案特别适用于具有方向性结构的医学图像分割任务,如血管、器官等,在计算复杂度和分割精度之间实现了良好平衡。

2501_91516851的博客 327

【系统分析师】高分论文:论面向对象设计方法及应用

本文以某市职业院校合并后的信息化系统建设项目为例,探讨了面向对象设计方法在系统开发中的应用。作为系统分析师,作者在415.6万元、6个月工期的项目中,通过遵循单一职责原则(简化构件库)、依赖倒置原则(开发公共接口)和开闭原则(构件扩展)等设计原则,成功完成了5个子系统融合改造和4个智慧实训室建设。项目采用J2EE分层设计和SOA架构,最终在2022年11月顺利上线,提升了教学管理效率,获得用户好评。文章总结了设计原则对提高系统复用性、可维护性的重要作用,同时也反思了部署过程中的经验教训。

数据知道的博客 7955

内存溢出在linux怎么定位_linux下定位多线程内存越界问题实践总结

c/c++ linux后台服务器开发学习地址:https://ke.qq.com/course/417774?flowToken=1013300最近定位了在一个多线程服务器程序(OceanBase MergeServer)中,一个线程非法篡改另一个线程的内存而导致程序core掉的问题。定位这个问题历经曲折,尝试了各种内存调试的办法。往往感觉就要柳暗花明了,却发现又进入了另一个死胡同。最后...

weixin_34393451的博客 751

【软件测试】- 公有云、私有云、混合云简介以及如何在公有云与私有云上分别搭建测试环境

公有云、私有云以及混合云是目前云计算领域的三种主要模式。它们之间的区别主要在于部署方式、数据访问和管理控制的不同。在阿里云或腾讯云上使用 Kubernetes 和 Docker 搭建测试环境可以提供高度的可伸缩性和灵活性。这样的环境非常适合于需要在多种配置和条件下进行测试的复杂应用。通过云服务提供的工具和服务,可以轻松管理和监控整个测试过程。在云服务上搭建测试环境涉及选择合适的云提供商、配置云资源、安装和设置所需软件等步骤。### 1. 选择云服务提供商。

weixin_46453070的博客 2342

走下神坛的内存调试器--定位多线程内存越界问题实践总结

转自http://www.cnblogs.com/djinmusic/archive/2013/02/04/2891753.html 最近定位了在一个多线程服务器程序(OceanBase MergeServer)中,一个线程非法篡改另一个线程的内存而导致程序core掉的问题。定位这个问题花了整整一周的时间,期间历经曲折,尝试了各种内存调试的办法。往往感觉就要柳暗花明了,却发现又进入了

多多要会飞了 1811

霍尼韦尔SoftMaster使用说明

霍尼韦尔SoftMaster使用说明

VC中sqlite的使用

在VC中对sqlite的使用,实现数据库大批数据的快速插入,读取数据库中的数据在ListCtrl中显示

[转]定位多线程内存越界问题实践总结

定位多线程内存越界问题实践总结 2013/2/4 杨志丰 yangzhifeng83@gmail.com   关键字 多线程,内存越界,valgrind,electric-fence,mprotect,libsigsegv,glibc   最近定位了在一个多线程服务器程序(OceanBase MergeServer)中,一个线程非法篡改另一个线程的内存而导致程序

cpucooler2011的专栏 707

visio安装包

Microsoft Office Visio 是一款专业的制图软件。利用经过更新的形状、协作工具和数据链接图表,创建专业级图表以简化复杂的信息。

mobx使用数组提示越界_linux下定位多线程内存越界问题实践总结

c/c++ linux后台服务器开发学习地址:https://ke.qq.com/course/417774?flowToken=1013300最近定位了在一个多线程服务器程序(OceanBase MergeServer)中,一个线程非法篡改另一个线程的内存而导致程序core掉的问题。定位这个问题历经曲折,尝试了各种内存调试的办法。往往感觉就要柳暗花明了,却发现又进入了另一个死胡同。最后...

weixin_39927408的博客 158

定位多线程内存越界问题实践总结【转】

转自:https://blog.youkuaiyun.com/killmice/article/details/38443343#comments 定位多线程内存越界问题实践总结 2013/2/4 杨志丰 yangzhifeng83@gmail.com   关键字 多线程,内存越界,valgrind,electric-fence,mprotect,libsigsegv,glibc   最近定...

weixin_33748818的博客 170
上一篇: Android 启动性能优化 - kernel篇
下一篇: 使用cscope
killmice
博客等级 码龄14年 119粉丝 54原创
评论 5
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值